Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Loading