Skip to content
Draft
Show file tree
Hide file tree
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
165 changes: 108 additions & 57 deletions .github/workflows/release-ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,120 +8,171 @@ on:
required: true
type: string
dry_run:
description: "Build and validate without publishing"
description: "Build and validate without tagging or publishing"
required: true
default: true
type: boolean

permissions:
contents: read

concurrency:
group: release-ruby-${{ inputs.version }}
cancel-in-progress: false

jobs:
build:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
TAG_NAME: ruby/v${{ inputs.version }}
defaults:
run:
working-directory: clients/ruby
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false

- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
working-directory: clients/ruby
bundler-cache: true

- name: Verify version input
- name: Verify clean release candidate
run: |
set -Eeuo pipefail
# Reject malformed input early. Gem::Version is permissive
# (accepts 0.2.0, 0.2.0.rc.1, 0.2.0.alpha, 0.2.0.beta.1, ...)
# but rejects clear garbage like "x.y.z" or empty strings.
ruby -rrubygems -e "Gem::Version.new(ENV.fetch('VERSION'))"

# The version constant must already match the input -- bumping
# version.rb is the responsibility of the release-prep PR, not
# this workflow. Any drift here is a configuration error.
actual=$(ruby -e 'require_relative "lib/pgque/version"; print Pgque::VERSION')
test "$actual" = "$VERSION" || {
echo "version input ${VERSION} != lib/pgque/version.rb ${actual}"
IFS=$'\n\t'
git diff --exit-code
git diff-index --quiet --cached HEAD
ruby script/validate_release.rb "$VERSION" "$TAG_NAME"
git check-ref-format "refs/tags/$TAG_NAME"

if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null; then
echo "release tag $TAG_NAME already exists"
exit 1
}
else
rc=$?
if [ "$rc" -ne 2 ]; then
echo "failed to check release tag $TAG_NAME"
exit "$rc"
fi
fi

- name: Build gem
run: gem build pgque.gemspec
- name: Run tests and build gem
run: |
set -Eeuo pipefail
IFS=$'\n\t'
bundle exec rake test
gem build pgque.gemspec
ruby script/validate_release.rb "$VERSION" "$TAG_NAME" "./pgque-${VERSION}.gem"

- name: Verify built gem installs
run: |
set -Eeuo pipefail
mkdir -p /tmp/pgque-gem-check
gem install --install-dir /tmp/pgque-gem-check --no-document \
"./pgque-${VERSION}.gem"
GEM_PATH=/tmp/pgque-gem-check GEM_HOME=/tmp/pgque-gem-check \
ruby -e '
require "pgque"
expected = ENV.fetch("VERSION")
raise "version mismatch: expected #{expected}, got #{Pgque::VERSION}" \
unless Pgque::VERSION == expected
raise "Pgque::Client missing" unless defined?(Pgque::Client)
raise "Pgque::Consumer missing" unless defined?(Pgque::Consumer)
puts "install verified: pgque #{Pgque::VERSION}"
'
IFS=$'\n\t'
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
gem install --install-dir "$tmp" --no-document "./pgque-${VERSION}.gem"
GEM_PATH="$tmp" GEM_HOME="$tmp" ruby -e '
require "pgque"
expected = ENV.fetch("VERSION")
raise "version mismatch: expected #{expected}, got #{Pgque::VERSION}" \
unless Pgque::VERSION == expected
raise "Pgque::Client missing" unless defined?(Pgque::Client)
raise "Pgque::Consumer missing" unless defined?(Pgque::Consumer)
puts "install verified: pgque #{Pgque::VERSION}"
'

- name: Upload validated gem
uses: actions/upload-artifact@v4
with:
name: ruby-gem-${{ inputs.version }}
path: clients/ruby/pgque-${{ inputs.version }}.gem
if-no-files-found: error
retention-days: 1

- name: Show dry-run result
if: inputs.dry_run
run: |
set -Eeuo pipefail
IFS=$'\n\t'
echo "dry run complete: validated pgque ${VERSION}"
echo "real publish would create ${TAG_NAME} at ${GITHUB_SHA} and push the validated gem"

publish-rubygems:
if: ${{ !inputs.dry_run }}
needs: build
runs-on: ubuntu-latest
environment: rubygems
permissions:
# contents:write is required so `rake release` (invoked by
# rubygems/release-gem) can push the v${VERSION} git tag back to
# origin. id-token:write is for the OIDC handshake with
# rubygems.org.
contents: write
id-token: write
env:
VERSION: ${{ inputs.version }}
TAG_NAME: ruby/v${{ inputs.version }}
defaults:
run:
working-directory: clients/ruby
steps:
# Check out the branch ref (not the bare SHA) so we land on an
# attached HEAD; bundler's release:source_control_push runs plain
# `git push`, which fails from detached HEAD. The build job (run
# immediately before this one) already verified the version
# against the dispatch SHA, so any race with main moving during
# the workflow would have to land a new commit AND a version
# bump in that window -- vanishingly small.
# Tag exactly the SHA validated by the build job. An explicit tag push
# works from detached HEAD and cannot race with a later main revision.
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
# Full history so existing tags are visible to release:guard_clean.
ref: ${{ github.sha }}
fetch-depth: 0

- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
working-directory: clients/ruby
bundler-cache: true

- name: Configure git identity for tag push
- uses: actions/download-artifact@v4
with:
name: ruby-gem-${{ inputs.version }}
path: clients/ruby

- name: Install release tooling
run: gem install rubygems-await --version 0.5.4 --no-document

- name: Revalidate artifact and tag
run: |
git config user.name "github-actions[bot]"
set -Eeuo pipefail
IFS=$'\n\t'
git diff --exit-code
git diff-index --quiet --cached HEAD
ruby script/validate_release.rb "$VERSION" "$TAG_NAME" "./pgque-${VERSION}.gem"
git check-ref-format "refs/tags/$TAG_NAME"

if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null; then
echo "release tag $TAG_NAME already exists"
exit 1
else
rc=$?
if [ "$rc" -ne 2 ]; then
echo "failed to check release tag $TAG_NAME"
exit "$rc"
fi
fi

- name: Configure RubyGems trusted-publishing credentials
uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0

- name: Create namespaced Ruby release tag
run: |
set -Eeuo pipefail
IFS=$'\n\t'
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG_NAME" "$GITHUB_SHA" -m "Ruby client ${VERSION}"
git push origin "refs/tags/$TAG_NAME"

# rubygems/release-gem performs the OIDC handshake (no long-lived
# RUBYGEMS_API_KEY secret needed) and then runs
# `bundle exec rake release`, which depends on the gem tasks
# provided by `require "bundler/gem_tasks"` in clients/ruby/Rakefile.
# The chain is: build -> release:guard_clean -> release:source_control_push
# (tags v${VERSION} and pushes the tag) -> release:rubygem_push.
- name: Publish to RubyGems via OIDC
uses: rubygems/release-gem@v1
with:
working-directory: clients/ruby
setup-trusted-publisher: true
await-release: true
- name: Publish validated gem to RubyGems
run: gem push "./pgque-${VERSION}.gem"

- name: Wait for release to propagate
run: gem exec --version 0.5.4 rubygems-await "./pgque-${VERSION}.gem"
63 changes: 33 additions & 30 deletions clients/ruby/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ The release workflow is `.github/workflows/release-ruby.yml`.
4. Ensure the gem already exists on RubyGems and Trusted Publishing
is configured (bootstrap section above).
5. Run **Release Ruby client** with `dry_run=true` first. Dry runs
only build, validate the version match, and smoke-install the
resulting `.gem`; they do not require the `rubygems` environment
approval or OIDC permissions.
validate the clean tree, version and namespaced tag, run the test suite,
build and inspect the `.gem`, smoke-install it, and confirm the tag is
available. They do not create a tag, publish, or require the `rubygems`
environment approval or OIDC permissions.
6. Run it with `dry_run=false`. Approve the `rubygems` environment
when prompted.
7. Verify the published artifact installs in a clean environment:
Expand All @@ -88,33 +89,35 @@ The release workflow is `.github/workflows/release-ruby.yml`.
ruby -rpgque -e 'puts Pgque::VERSION'
```

The workflow builds with `gem build`, smoke-installs the resulting
`.gem` against a temporary `GEM_HOME`, and publishes via RubyGems
Trusted Publishing / OIDC. No long-lived `RUBYGEMS_API_KEY` is
needed.

The publish step uses `rubygems/release-gem@v1`, which runs
`bundle exec rake release`. That task (provided by
`require "bundler/gem_tasks"` in `clients/ruby/Rakefile`) chains:

1. `rake build` — builds `pgque-${VERSION}.gem` under `pkg/`.
2. `release:guard_clean` — refuses to release if the working tree
has uncommitted changes (CI checkouts are clean).
3. `release:source_control_push` — annotates the head commit with a
`v${VERSION}` tag and pushes that tag to `origin`. The
`contents: write` permission on the publish job, plus the
`GITHUB_TOKEN` automatically injected by `actions/checkout`, is
what authorizes the push. **The release workflow therefore
pushes a git tag to `NikolayS/pgque` as a side effect.** If you
need to retract a release, yank the gem on RubyGems *and* delete
the tag with `git push --delete origin v${VERSION}`.
4. `release:rubygem_push` — `gem push pkg/pgque-${VERSION}.gem`.

If the gem push fails after the tag has already been pushed (rare
but possible if rubygems.org is degraded), you'll have a `v${VERSION}`
tag with no corresponding published gem. Re-running the workflow
will then fail at `release:guard_clean` if the tag already exists;
delete the tag and re-dispatch.
The workflow builds with `gem build`, smoke-installs the resulting `.gem`
against a temporary `GEM_HOME`, and uploads that exact artifact to the publish
job. The publish job revalidates the artifact, obtains short-lived credentials
through RubyGems Trusted Publishing / OIDC, creates the annotated tag
`ruby/v${VERSION}` at the dispatch SHA, pushes the tag, and publishes with
`gem push`. No long-lived `RUBYGEMS_API_KEY` is needed.

Ruby client tags are deliberately namespaced. Never use plain `v${VERSION}` for
a gem release: that namespace belongs to PgQue SQL/server releases, whose
version is independent from the Ruby client.

If `gem push` fails after the namespaced tag has been pushed, delete only that
Ruby tag, then re-dispatch:

```bash
git push origin --delete ruby/v0.3.0
```

If the workflow fails only while waiting for propagation, check RubyGems first:
the publish may already have succeeded, and retrying or yanking is unnecessary.

To retract a genuinely bad release, yank the gem and remove its namespaced tag:

```bash
gem yank pgque -v 0.3.0
git push origin --delete ruby/v0.3.0
```

Do not delete the SQL/server `v0.3.0` tag when retracting a Ruby client release.

## Why no test registry?

Expand Down
1 change: 0 additions & 1 deletion clients/ruby/Rakefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
require "bundler/gem_tasks"
require "rake/testtask"

Rake::TestTask.new(:test) do |t|
Expand Down
39 changes: 39 additions & 0 deletions clients/ruby/script/validate_release.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

# Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license.

require "rubygems"
require "rubygems/package"
require_relative "../lib/pgque/version"

abort "usage: validate_release.rb VERSION TAG_NAME [GEM_PATH]" unless [2, 3].include?(ARGV.length)

version, tag_name, gem_path = ARGV

unless version.match?(/\A\d+\.\d+\.\d+(?:\.[0-9A-Za-z]+)*\z/) &&
Gem::Version.correct?(version) && Gem::Version.new(version).to_s == version
abort "invalid canonical RubyGems version: #{version.inspect}"
end

expected_tag = "ruby/v#{version}"
unless tag_name == expected_tag
abort "tag must be #{expected_tag.inspect}, got #{tag_name.inspect}"
end
unless Pgque::VERSION == version
abort "version input #{version.inspect} != Pgque::VERSION #{Pgque::VERSION.inspect}"
end

if gem_path
expected_file = "pgque-#{version}.gem"
unless File.basename(gem_path) == expected_file
abort "gem filename must be #{expected_file.inspect}, got #{File.basename(gem_path).inspect}"
end
abort "gem artifact not found: #{gem_path}" unless File.file?(gem_path)

spec = Gem::Package.new(gem_path).spec
abort "gem name must be \"pgque\", got #{spec.name.inspect}" unless spec.name == "pgque"
abort "gem version #{spec.version} != expected #{version}" unless spec.version.to_s == version
end

source = gem_path ? " from #{gem_path}" : ""
puts "release candidate verified: pgque #{version} (#{expected_tag})#{source}"
Loading
Loading