diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 5886b41..e1abe25 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,14 +1,86 @@ +# Publishes a new version to RubyGems.org with trusted publishing (OIDC) once +# CI passes on main, if the gemspec's version isn't on RubyGems yet, then creates +# its GitHub release. Bumping the version in singed.gemspec is the release. +# Dispatch it from main to retry a failed run: it skips whatever's already done. +# A dispatch doesn't wait for CI, so only run it when main is green; from any +# other branch it does nothing. +# +# The filename and the rubygems.org environment are what the trusted publisher +# on RubyGems.org is registered against, so don't rename either. name: Push Gem on: + # zizmor: ignore[dangerous-triggers] only a push to this repo's main gets past the check job, and the release job checks out main itself + workflow_run: + workflows: [CI Test] + types: [completed] + branches: [main] workflow_dispatch: permissions: contents: read +concurrency: + group: push-gem + cancel-in-progress: false + jobs: - push: - if: github.repository == 'rubyatscale/singed' + check: + name: Check whether a release is needed + # A fork's pull request can come from a branch named main, so require a push to this repo. + if: >- + github.repository == 'rubyatscale/singed' && + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' || + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-latest + outputs: + release: ${{ steps.version.outputs.release }} + github_release: ${{ steps.version.outputs.github_release }} + version: ${{ steps.version.outputs.version }} + sha: ${{ steps.version.outputs.sha }} + steps: + # Reads the gemspec through the API instead of checking it out and evaluating it, so no + # code from the triggering commit runs in this privileged context. + - name: Check whether this version is on RubyGems and has a GitHub release + id: version + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event.workflow_run.head_sha || github.sha }} + run: | + gemspec=$(gh api "repos/$GITHUB_REPOSITORY/contents/singed.gemspec?ref=$SHA" -H 'Accept: application/vnd.github.raw') + version=$(sed -n 's/^[[:space:]]*spec\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$gemspec" | head -1) + if ! [[ "$version" =~ ^[0-9]+(\.[0-9A-Za-z]+)*$ ]]; then + echo "::error::Couldn't read a version from the spec.version line in singed.gemspec at $SHA" + exit 1 + fi + status=$(curl -sS --retry 3 -o /dev/null -w '%{http_code}' "https://rubygems.org/api/v2/rubygems/singed/versions/$version.json") + case "$status" in + 200) release=false; echo "singed $version is already on RubyGems." ;; + 404) release=true; echo "singed $version isn't on RubyGems yet; releasing it." ;; + *) echo "::error::RubyGems returned HTTP $status for singed $version"; exit 1 ;; + esac + if lookup=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/v$version" 2>&1); then + github_release=false + elif grep -q 'HTTP 404' <<<"$lookup"; then + github_release=true + echo "v$version has no GitHub release yet." + else + echo "::error::Couldn't check for a v$version GitHub release: $lookup" + exit 1 + fi + { + echo "release=$release" + echo "github_release=$github_release" + echo "version=$version" + echo "sha=$SHA" + } >> "$GITHUB_OUTPUT" + + release: + name: Release + needs: check + if: needs.check.outputs.release == 'true' runs-on: ubuntu-latest environment: @@ -16,26 +88,70 @@ jobs: url: https://rubygems.org/gems/singed permissions: - contents: write - id-token: write + contents: write # push the version tag + id-token: write # trusted publishing steps: - # Set up - name: Harden Runner uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit + # `rake release` pushes the current branch along with the tag, so this has to be a + # branch checkout rather than a detached HEAD at the tested commit. The push needs no + # persisted credentials: release-gem puts the token in git's credential cache for it. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: main persist-credentials: false + - name: Confirm main is still the commit CI tested + env: + SHA: ${{ needs.check.outputs.sha }} + run: | + head=$(git rev-parse HEAD) + if [ "$head" != "$SHA" ]; then + echo "::error::main moved from $SHA to $head after CI passed. If CI passes on $head, its run will release this version; otherwise dispatch this workflow once main is green." + exit 1 + fi + # No bundler cache here: a job that publishes the gem shouldn't restore one. - name: Set up Ruby uses: ruby/setup-ruby@e8944e80fb94b20106697132f8c20c665fab29e9 # v1.325.0 with: ruby-version: ruby - # Not bundler-cache: a release shouldn't restore a cache other workflows can write. - - name: Install gems - run: bundle install + - run: bundle install - # Release - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1 + + # Its own job, so a retry can still create the release after the gem has shipped. + github_release: + name: Create GitHub release + needs: [check, release] + if: >- + always() && needs.check.result == 'success' && needs.check.outputs.github_release == 'true' && + (needs.release.result == 'success' || needs.release.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + steps: + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.check.outputs.version }} + # RubyGems treats any version with a letter in it as a prerelease. + run: | + prerelease=() + if [[ "$VERSION" == *[A-Za-z]* ]]; then prerelease=(--prerelease); fi + gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes "${prerelease[@]}" + + notify_on_failure: + name: Notify on failure + needs: [check, release, github_release] + if: failure() + runs-on: ubuntu-latest + steps: + - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + text: "${{ github.repository }} Push Gem workflow FAILED\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"