From fa74cd4388945f9a617b53caa965489cd5cff3f4 Mon Sep 17 00:00:00 2001 From: Nik Samokhvalov Date: Fri, 10 Jul 2026 02:27:55 -0700 Subject: [PATCH 1/4] fix(release): namespace Ruby gem tags --- .github/workflows/release-ruby.yml | 156 +++++++++++++++--------- clients/ruby/RELEASE.md | 63 +++++----- clients/ruby/Rakefile | 1 - clients/ruby/script/validate_release.rb | 39 ++++++ 4 files changed, 171 insertions(+), 88 deletions(-) create mode 100644 clients/ruby/script/validate_release.rb diff --git a/.github/workflows/release-ruby.yml b/.github/workflows/release-ruby.yml index 7e64dee3..0e072b7b 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -8,7 +8,7 @@ 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 @@ -16,12 +16,17 @@ on: 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 @@ -29,6 +34,8 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false - uses: ruby/setup-ruby@v1 with: @@ -36,42 +43,62 @@ jobs: 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}" + 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 + 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}" - ' + 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 + 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 }} @@ -79,49 +106,64 @@ jobs: 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: Revalidate artifact and tag run: | - git config user.name "github-actions[bot]" + set -Eeuo pipefail + 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 + 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 rubygems-await "./pgque-${VERSION}.gem" diff --git a/clients/ruby/RELEASE.md b/clients/ruby/RELEASE.md index 204e6b0f..0417d90c 100644 --- a/clients/ruby/RELEASE.md +++ b/clients/ruby/RELEASE.md @@ -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: @@ -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? diff --git a/clients/ruby/Rakefile b/clients/ruby/Rakefile index ecb388b9..174c5caa 100644 --- a/clients/ruby/Rakefile +++ b/clients/ruby/Rakefile @@ -1,4 +1,3 @@ -require "bundler/gem_tasks" require "rake/testtask" Rake::TestTask.new(:test) do |t| diff --git a/clients/ruby/script/validate_release.rb b/clients/ruby/script/validate_release.rb new file mode 100644 index 00000000..595441d4 --- /dev/null +++ b/clients/ruby/script/validate_release.rb @@ -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}" From 40aa3722535494a517ec0871026834f289df0e58 Mon Sep 17 00:00:00 2001 From: Nik Samokhvalov Date: Sat, 11 Jul 2026 06:50:23 -0700 Subject: [PATCH 2/4] test(ruby): cover release validator --- clients/ruby/test/test_validate_release.rb | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 clients/ruby/test/test_validate_release.rb diff --git a/clients/ruby/test/test_validate_release.rb b/clients/ruby/test/test_validate_release.rb new file mode 100644 index 00000000..7376d7bd --- /dev/null +++ b/clients/ruby/test/test_validate_release.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. + +require "minitest/autorun" +require "open3" +require "rbconfig" +require_relative "../lib/pgque/version" + +class TestValidateRelease < Minitest::Test + SCRIPT = File.expand_path("../script/validate_release.rb", __dir__) + + def run_validator(*arguments) + Open3.capture3(RbConfig.ruby, SCRIPT, *arguments) + end + + def test_accepts_matching_version_and_namespaced_tag + output, error, status = run_validator( + Pgque::VERSION, "ruby/v#{Pgque::VERSION}" + ) + + assert status.success?, error + assert_includes output, "release candidate verified: pgque #{Pgque::VERSION}" + end + + def test_rejects_unnamespaced_tag + _output, error, status = run_validator(Pgque::VERSION, "v#{Pgque::VERSION}") + + refute status.success? + assert_includes error, "tag must be \"ruby/v#{Pgque::VERSION}\"" + end + + def test_rejects_noncanonical_version + _output, error, status = run_validator("0.3.0-rc.1", "ruby/v0.3.0-rc.1") + + refute status.success? + assert_includes error, "invalid canonical RubyGems version" + end + + def test_rejects_version_that_differs_from_library + _output, error, status = run_validator("9.9.9", "ruby/v9.9.9") + + refute status.success? + assert_includes error, "!= Pgque::VERSION" + end + + def test_rejects_missing_artifact + missing = "pgque-#{Pgque::VERSION}.gem" + _output, error, status = run_validator( + Pgque::VERSION, "ruby/v#{Pgque::VERSION}", missing + ) + + refute status.success? + assert_includes error, "gem artifact not found" + end +end From f7f4ae99e30c12a0c5bf869e51e62fce870c9bb6 Mon Sep 17 00:00:00 2001 From: Nik Samokhvalov Date: Sat, 11 Jul 2026 07:16:04 -0700 Subject: [PATCH 3/4] chore(ruby): test release workflow safety --- clients/ruby/test/test_validate_release.rb | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/clients/ruby/test/test_validate_release.rb b/clients/ruby/test/test_validate_release.rb index 7376d7bd..19d13546 100644 --- a/clients/ruby/test/test_validate_release.rb +++ b/clients/ruby/test/test_validate_release.rb @@ -5,15 +5,46 @@ require "minitest/autorun" require "open3" require "rbconfig" +require "yaml" require_relative "../lib/pgque/version" class TestValidateRelease < Minitest::Test SCRIPT = File.expand_path("../script/validate_release.rb", __dir__) + WORKFLOW = File.expand_path("../../../.github/workflows/release-ruby.yml", __dir__) def run_validator(*arguments) Open3.capture3(RbConfig.ruby, SCRIPT, *arguments) end + def workflow_steps + YAML.safe_load_file(WORKFLOW).fetch("jobs").values.flat_map do |job| + job.fetch("steps") + end + end + + def test_release_workflow_pins_privileged_gem_tooling + install = workflow_steps.find { |step| step["name"] == "Install release tooling" } + await_step = workflow_steps.find { |step| step["name"] == "Wait for release to propagate" } + + assert_equal "gem install rubygems-await --version 0.5.4 --no-document", install&.fetch("run") + assert_equal "gem exec --version 0.5.4 rubygems-await \"./pgque-${VERSION}.gem\"", await_step.fetch("run") + end + + def test_release_workflow_shell_contract + scripts = workflow_steps.filter_map { |step| step["run"] } + scripts.grep(/set -Eeuo pipefail/).each do |script| + assert_match(/\Aset -Eeuo pipefail\nIFS=\$'\\n\\t'\n/, script) + end + + combined = scripts.join("\n") + refute_includes combined, "$(seq " + if combined.match?(/\bpsql\b/) + assert_includes combined, "PAGER=cat" + assert_includes combined, "psql --no-psqlrc --set=ON_ERROR_STOP=1" + refute_match(/\bpsql\b[^\n]*\s-v(?:\s|$)/, combined) + end + end + def test_accepts_matching_version_and_namespaced_tag output, error, status = run_validator( Pgque::VERSION, "ruby/v#{Pgque::VERSION}" From 48a1c87d1a055c6fe9cc404fbec38af5195293a5 Mon Sep 17 00:00:00 2001 From: Nik Samokhvalov Date: Sat, 11 Jul 2026 07:16:24 -0700 Subject: [PATCH 4/4] fix(ruby): harden release workflow tools --- .github/workflows/release-ruby.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-ruby.yml b/.github/workflows/release-ruby.yml index 0e072b7b..48160bf4 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -46,6 +46,7 @@ jobs: - name: Verify clean release candidate run: | set -Eeuo pipefail + IFS=$'\n\t' git diff --exit-code git diff-index --quiet --cached HEAD ruby script/validate_release.rb "$VERSION" "$TAG_NAME" @@ -65,6 +66,7 @@ jobs: - 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" @@ -72,6 +74,7 @@ jobs: - name: Verify built gem installs run: | set -Eeuo pipefail + IFS=$'\n\t' tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT gem install --install-dir "$tmp" --no-document "./pgque-${VERSION}.gem" @@ -97,6 +100,7 @@ jobs: 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" @@ -132,9 +136,13 @@ jobs: 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: | 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" @@ -157,6 +165,7 @@ jobs: - 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}" @@ -166,4 +175,4 @@ jobs: run: gem push "./pgque-${VERSION}.gem" - name: Wait for release to propagate - run: gem exec rubygems-await "./pgque-${VERSION}.gem" + run: gem exec --version 0.5.4 rubygems-await "./pgque-${VERSION}.gem"