diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..79b7118 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +# Publishes the gem to RubyGems.org from GitHub Actions, the way logtail-js and logtail-python +# release their packages. The publish job runs in the "rubygems" environment, which only deploys +# from main; anyone who can dispatch the workflow can release. +# +# Authentication: the repository secret RUBYGEMS_CREDENTIALS holds the contents of a gem +# credentials file for the shared rubygems.org account, i.e. ":rubygems_api_key: rubygems_…", +# the line the manual process kept in ~/.local/share/gem/credentials. +# +# Repository settings this relies on: the "rubygems" environment with deployment branches limited +# to main and no required reviewers (add reviewers there if releases should need an approval; a +# job that references a missing environment would create it WITHOUT protection), and the GitHub +# Actions app among the actors allowed to bypass the pull request requirement of main, which the +# version commit needs. +# +# Release: Actions → Release → Run workflow from main, pick patch or minor. The workflow runs the +# tests, bumps the version in lib/*/version.rb, builds the gem, commits "vX.Y.Z", tags it, pushes +# both, pushes the gem to RubyGems.org and creates a GitHub release with auto-generated notes for +# the tag; edit the notes afterwards if needed. +# +# Retry: if a release failed after the version commit was pushed, run "retry" right away from +# main. It bumps nothing, rebuilds the tagged commit, pushes the gem if RubyGems.org is still +# missing it and creates the GitHub release if it is still missing. Running patch or minor again +# would release the next version instead. +# +# Dry run: bumps in place without committing, builds the gem and checks the credentials secret, +# publishes nothing. Every push that touches this file is a dry run, so a change to the workflow +# proves itself on its pull request before it reaches main. A dry run can also be dispatched from +# any branch. +name: Release + +on: + workflow_dispatch: + inputs: + release: + description: "patch or minor: bump, tag and publish. retry: finish a release that failed halfway." + type: choice + options: [patch, minor, retry] + required: true + dry_run: + description: "Dry run: bump and build, check the credentials, publish nothing" + type: boolean + default: false + push: + paths: + - .github/workflows/release.yml + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +env: + DRY_RUN: ${{ github.event_name == 'push' || inputs.dry_run }} + RELEASE: ${{ inputs.release || 'patch' }} + +jobs: + verify: + name: Test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3" + bundler-cache: true + + - name: Run tests + run: bundle exec rspec + + build: + name: Bump and build + needs: verify + runs-on: ubuntu-24.04 + permissions: + contents: write # pushes the version commit and the tag + outputs: + version: ${{ steps.version.outputs.version }} + gem: ${{ steps.build.outputs.gem }} + + steps: + - name: Releases run from main only + if: ${{ env.DRY_RUN != 'true' && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Dispatch the release from main, not from $GITHUB_REF_NAME." + exit 1 + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the version check looks at the tags on HEAD + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3" + + - name: Bump version + id: version + run: | + ruby - <<'EOF' + release, dry_run = ENV.fetch("RELEASE"), ENV["DRY_RUN"] == "true" + path = Dir["lib/**/version.rb"].fetch(0) + source = File.read(path) + current = source[/VERSION = "(\d+\.\d+\.\d+)"/, 1] || abort("no VERSION constant in #{path}") + tagged = `git tag --points-at HEAD`.split.include?("v#{current}") + + if release == "retry" + abort("retry only finishes a release whose version commit v#{current} is HEAD") unless tagged + version = current + else + abort("HEAD is already released as v#{current}, there is nothing new to release") if tagged && !dry_run + major, minor, patch = current.split(".").map(&:to_i) + version = release == "minor" ? "#{major}.#{minor + 1}.0" : "#{major}.#{minor}.#{patch + 1}" + File.write(path, source.sub(%("#{current}"), %("#{version}"))) + end + + puts "#{current} -> #{version}" + File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts "version=#{version}" } + EOF + + - name: Build the gem + id: build + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + gem build *.gemspec + gem=$(ls *.gem) + case "$gem" in + *-"$VERSION".gem) ;; + *) echo "::error::Built $gem, expected version $VERSION."; exit 1 ;; + esac + echo "gem=$gem" >> "$GITHUB_OUTPUT" + + - name: Dry run + if: ${{ env.DRY_RUN == 'true' }} + env: + RUBYGEMS_CREDENTIALS: ${{ secrets.RUBYGEMS_CREDENTIALS }} + run: | + git diff --stat + if ! grep -q '^:rubygems_api_key: [^ ]' <<< "$RUBYGEMS_CREDENTIALS"; then + echo "::error::The RUBYGEMS_CREDENTIALS secret must hold the contents of a gem credentials file: ':rubygems_api_key: rubygems_…'" + exit 1 + fi + echo "RUBYGEMS_CREDENTIALS looks like a gem credentials file. A release pushes with it, nothing more to verify without pushing." + + - name: Commit and tag + if: ${{ env.DRY_RUN != 'true' && env.RELEASE != 'retry' }} + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "v$VERSION" + git tag -a "v$VERSION" -m "v$VERSION" + git push origin main "v$VERSION" + + - uses: actions/upload-artifact@v7 + with: + name: gem + path: "*.gem" + if-no-files-found: error + + release: + name: Publish + needs: build + if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run }} + runs-on: ubuntu-24.04 + environment: rubygems + permissions: + contents: write # creates the GitHub release + env: + VERSION: ${{ needs.build.outputs.version }} + GEM: ${{ needs.build.outputs.gem }} + + steps: + - uses: actions/download-artifact@v8 + with: + name: gem + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3" + + - name: Push to RubyGems.org + env: + RUBYGEMS_CREDENTIALS: ${{ secrets.RUBYGEMS_CREDENTIALS }} + run: | + name="${GEM%-$VERSION.gem}" + if gem specification --remote "$name" --version "$VERSION" > /dev/null 2>&1; then + echo "$name $VERSION is on rubygems.org already, finishing the release." + else + mkdir -p ~/.gem + (umask 077 && printf '%s\n' "$RUBYGEMS_CREDENTIALS" > ~/.gem/credentials) + gem push "$GEM" + fi + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "v$VERSION" > /dev/null 2>&1; then + echo "Release v$VERSION already exists." + else + # --verify-tag: only ever attach to the tag the build job pushed, never create one here. + gh release create "v$VERSION" --verify-tag --generate-notes + fi