diff --git a/.github/workflows/release-ruby.yml b/.github/workflows/release-ruby.yml index 48160bf4..ca6841c8 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -22,11 +22,25 @@ concurrency: jobs: build: - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || inputs.dry_run runs-on: ubuntu-latest + services: + postgres: + image: postgres:18@sha256:22c89fe0d0f507606260237fd55e51f6137f58b2d5bcf6152242b96d9fe8f9a4 + env: + POSTGRES_PASSWORD: pgque_test + POSTGRES_DB: pgque_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready --username=postgres --dbname=pgque_test" + --health-interval 1s + --health-timeout 5s + --health-retries 30 env: VERSION: ${{ inputs.version }} TAG_NAME: ruby/v${{ inputs.version }} + PGQUE_TEST_DSN: postgresql://postgres:pgque_test@localhost:5432/pgque_test defaults: run: working-directory: clients/ruby @@ -36,6 +50,7 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false + submodules: recursive - uses: ruby/setup-ruby@v1 with: @@ -63,11 +78,59 @@ jobs: fi fi + - name: Verify RubyGems version is available + run: | + set -Eeuo pipefail + IFS=$'\n\t' + response=$(mktemp) + trap 'rm -f "$response"' EXIT + curl --fail-with-body --silent --show-error \ + --retry 3 --retry-all-errors \ + --connect-timeout 10 --max-time 30 \ + -H 'Accept: application/json' \ + https://rubygems.org/api/v1/versions/pgque.json \ + -o "$response" + ruby script/check_rubygems_version.rb "$VERSION" "$response" + + - name: Build and install current PgQue SQL + working-directory: ${{ github.workspace }} + run: | + set -Eeuo pipefail + IFS=$'\n\t' + export PAGER=cat + psql_base=(psql --no-psqlrc --set=ON_ERROR_STOP=1 "$PGQUE_TEST_DSN") + ready_checks=0 + for ((_attempt = 1; _attempt <= 30; _attempt++)); do + if "${psql_base[@]}" --command='select 1' >/dev/null 2>&1; then + ready_checks=$((ready_checks + 1)) + if [ "$ready_checks" -ge 2 ]; then + break + fi + else + ready_checks=0 + fi + sleep 1 + done + if [ "$ready_checks" -lt 2 ]; then + echo "PostgreSQL did not remain ready for two consecutive checks" + exit 1 + fi + + bash build/transform.sh + if ! git diff --exit-code -- devel/sql/pgque.sql devel/sql/pgque-tle.sql; then + echo "generated PgQue SQL is stale; run build/transform.sh and commit the result" + exit 1 + fi + "${psql_base[@]}" --file=devel/sql/pgque.sql + - name: Run tests and build gem run: | set -Eeuo pipefail IFS=$'\n\t' - bundle exec rake test + test_log=$(mktemp) + trap 'rm -f "$test_log"' EXIT + bundle exec rake test 2>&1 | tee "$test_log" + ruby script/assert_no_test_skips.rb "$test_log" gem build pgque.gemspec ruby script/validate_release.rb "$VERSION" "$TAG_NAME" "./pgque-${VERSION}.gem" @@ -159,6 +222,20 @@ jobs: fi fi + - name: Recheck RubyGems version availability + run: | + set -Eeuo pipefail + IFS=$'\n\t' + response=$(mktemp) + trap 'rm -f "$response"' EXIT + curl --fail-with-body --silent --show-error \ + --retry 3 --retry-all-errors \ + --connect-timeout 10 --max-time 30 \ + -H 'Accept: application/json' \ + https://rubygems.org/api/v1/versions/pgque.json \ + -o "$response" + ruby script/check_rubygems_version.rb "$VERSION" "$response" + - name: Configure RubyGems trusted-publishing credentials uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 diff --git a/clients/ruby/RELEASE.md b/clients/ruby/RELEASE.md index 0417d90c..12448903 100644 --- a/clients/ruby/RELEASE.md +++ b/clients/ruby/RELEASE.md @@ -27,7 +27,7 @@ pre-release; users need `gem install pgque --pre` to receive it. RubyGems' Trusted Publishing requires the gem to **already exist** on the registry before a trusted publisher can be configured. The very -first release is therefore manual: +first release was therefore manual: ```bash cd clients/ruby @@ -36,7 +36,12 @@ gem signin # one-time, prompts for rubygems.org credentials gem push pgque-0.3.0.rc.1.gem ``` -After that, every subsequent release goes through the workflow below. +That bootstrap publish is complete: `pgque 0.3.0.rc.1` already exists on +RubyGems. Published RubyGems versions are immutable, so the workflow must never +be dispatched with `0.3.0.rc.1`, even if its namespaced Git tag does not exist. +The next attempt must first bump `Pgque::VERSION` to a new version (for example, +`0.3.0.rc.2`). Every release after the bootstrap goes through the workflow +below. ## GitHub environment prerequisite @@ -75,26 +80,31 @@ The release workflow is `.github/workflows/release-ruby.yml`. 3. Ensure the `rubygems` GitHub environment exists and is protected. 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 - 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. +5. Run **Release Ruby client** with `dry_run=true` first. Dry runs validate the + clean tree, version and namespaced tag, confirm the version is not already on + RubyGems, install the current development SQL into a pinned PostgreSQL 18 + service, and run the full database-backed Ruby suite. Any skipped test fails + the release check. They then build and inspect the `.gem`, smoke-install it, + and confirm the tag is available. Dry runs 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: ```bash - gem install pgque --pre # or pin: gem install pgque -v 0.3.0.rc.1 + VERSION=0.3.0.rc.2 # replace with the version just published + gem install pgque -v "$VERSION" ruby -rpgque -e 'puts Pgque::VERSION' ``` -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. +The workflow checks RubyGems version availability before uploading the artifact, +then 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 both the artifact and RubyGems availability immediately +before tagging, 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 diff --git a/clients/ruby/lib/pgque/version.rb b/clients/ruby/lib/pgque/version.rb index 9ce00d79..7d369ae7 100644 --- a/clients/ruby/lib/pgque/version.rb +++ b/clients/ruby/lib/pgque/version.rb @@ -1,5 +1,5 @@ # Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. module Pgque - VERSION = "0.3.0.rc.1" + VERSION = "0.3.0.rc.2" end diff --git a/clients/ruby/script/assert_no_test_skips.rb b/clients/ruby/script/assert_no_test_skips.rb new file mode 100644 index 00000000..aaedf9b7 --- /dev/null +++ b/clients/ruby/script/assert_no_test_skips.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. + +module PgqueRelease + module TestSkipCheck + module_function + + SUMMARY_PATTERN = /^\d+ runs, \d+ assertions, \d+ failures, \d+ errors, (\d+) skips$/ + + def check!(output) + summaries = output.scan(SUMMARY_PATTERN).flatten + raise ArgumentError, "Minitest summary not found" if summaries.empty? + + skip_count = Integer(summaries.last, 10) + if skip_count.positive? + raise ArgumentError, "Ruby release test suite reported #{skip_count} skipped test(s)" + end + + true + end + end +end + +if $PROGRAM_NAME == __FILE__ + abort "usage: assert_no_test_skips.rb TEST_LOG" unless ARGV.length == 1 + + begin + PgqueRelease::TestSkipCheck.check!(File.read(ARGV.fetch(0))) + rescue ArgumentError => e + abort e.message + end + puts "Ruby release test suite reported zero skips" +end diff --git a/clients/ruby/script/check_rubygems_version.rb b/clients/ruby/script/check_rubygems_version.rb new file mode 100644 index 00000000..fc6a26c1 --- /dev/null +++ b/clients/ruby/script/check_rubygems_version.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. + +require "json" +require "rubygems" + +module PgqueRelease + module RubyGemsVersionCheck + module_function + + VERSION_PATTERN = /\A\d+\.\d+\.\d+(?:\.[0-9A-Za-z]+)*\z/ + + def check!(version, response) + unless VERSION_PATTERN.match?(version) && + Gem::Version.correct?(version) && Gem::Version.new(version).to_s == version + raise ArgumentError, "invalid canonical RubyGems version: #{version.inspect}" + end + + versions = JSON.parse(response) + unless versions.is_a?(Array) + raise ArgumentError, "invalid RubyGems versions response: expected a JSON array" + end + + published_versions = versions.each_with_index.map do |entry, index| + number = entry["number"] if entry.is_a?(Hash) + unless number.is_a?(String) && !number.empty? + raise ArgumentError, + "invalid RubyGems versions response: entry #{index} has no string number" + end + number + end + + if published_versions.include?(version) + raise ArgumentError, + "pgque #{version} is already published on RubyGems and immutable; choose a new version" + end + + true + rescue JSON::ParserError => e + raise ArgumentError, "invalid RubyGems versions response: #{e.message}" + end + end +end + +if $PROGRAM_NAME == __FILE__ + abort "usage: check_rubygems_version.rb VERSION [RESPONSE_PATH]" unless [1, 2].include?(ARGV.length) + + version, response_path = ARGV + response = response_path ? File.read(response_path) : $stdin.read + begin + PgqueRelease::RubyGemsVersionCheck.check!(version, response) + rescue ArgumentError => e + abort e.message + end + puts "RubyGems version available: pgque #{version}" +end diff --git a/clients/ruby/test/fixtures/rubygems_versions/available.json b/clients/ruby/test/fixtures/rubygems_versions/available.json new file mode 100644 index 00000000..4a3bd7b6 --- /dev/null +++ b/clients/ruby/test/fixtures/rubygems_versions/available.json @@ -0,0 +1,14 @@ +[ + { + "authors": "PgQue maintainers", + "built_at": "2026-07-09T00:00:00.000Z", + "number": "0.3.0.rc.1", + "prerelease": true + }, + { + "authors": "PgQue maintainers", + "built_at": "2026-07-10T00:00:00.000Z", + "number": "0.3.0.rc.10", + "prerelease": true + } +] diff --git a/clients/ruby/test/fixtures/rubygems_versions/invalid.json b/clients/ruby/test/fixtures/rubygems_versions/invalid.json new file mode 100644 index 00000000..afb37d9f --- /dev/null +++ b/clients/ruby/test/fixtures/rubygems_versions/invalid.json @@ -0,0 +1 @@ +{"number": diff --git a/clients/ruby/test/fixtures/rubygems_versions/invalid_schema.json b/clients/ruby/test/fixtures/rubygems_versions/invalid_schema.json new file mode 100644 index 00000000..c6fd3f3f --- /dev/null +++ b/clients/ruby/test/fixtures/rubygems_versions/invalid_schema.json @@ -0,0 +1,3 @@ +{ + "number": "0.3.0.rc.1" +} diff --git a/clients/ruby/test/fixtures/rubygems_versions/missing_number.json b/clients/ruby/test/fixtures/rubygems_versions/missing_number.json new file mode 100644 index 00000000..1ddab8a1 --- /dev/null +++ b/clients/ruby/test/fixtures/rubygems_versions/missing_number.json @@ -0,0 +1,6 @@ +[ + { + "authors": "PgQue maintainers", + "prerelease": true + } +] diff --git a/clients/ruby/test/fixtures/rubygems_versions/published.json b/clients/ruby/test/fixtures/rubygems_versions/published.json new file mode 100644 index 00000000..010f6659 --- /dev/null +++ b/clients/ruby/test/fixtures/rubygems_versions/published.json @@ -0,0 +1,8 @@ +[ + { + "authors": "PgQue maintainers", + "built_at": "2026-07-09T00:00:00.000Z", + "number": "0.3.0.rc.1", + "prerelease": true + } +] diff --git a/clients/ruby/test/test_release_checks.rb b/clients/ruby/test/test_release_checks.rb new file mode 100644 index 00000000..650dc010 --- /dev/null +++ b/clients/ruby/test/test_release_checks.rb @@ -0,0 +1,96 @@ +# Copyright 2026 Nikolay Samokhvalov. Apache-2.0 license. + +require "minitest/autorun" +require "open3" +require "rbconfig" +require "yaml" +require_relative "../script/assert_no_test_skips" +require_relative "../script/check_rubygems_version" + +class TestReleaseChecks < Minitest::Test + FIXTURE_DIR = File.expand_path("fixtures/rubygems_versions", __dir__) + RUBYGEMS_CHECKER = File.expand_path("../script/check_rubygems_version.rb", __dir__) + RELEASE_WORKFLOW = File.expand_path("../../../.github/workflows/release-ruby.yml", __dir__) + + def fixture(name) + File.read(File.join(FIXTURE_DIR, name)) + end + + def test_branch_dry_run_executes_validation_without_enabling_publish + jobs = YAML.safe_load_file(RELEASE_WORKFLOW).fetch("jobs") + + assert_includes jobs.fetch("build").fetch("if"), "inputs.dry_run" + assert_equal "${{ !inputs.dry_run }}", jobs.fetch("publish-rubygems").fetch("if") + end + + def test_accepts_an_available_exact_version + assert PgqueRelease::RubyGemsVersionCheck.check!("0.3.0.rc.2", fixture("available.json")) + end + + def test_exact_matching_does_not_confuse_rc_1_and_rc_10 + assert PgqueRelease::RubyGemsVersionCheck.check!("0.3.0.rc.1", <<~JSON) + [{"number":"0.3.0.rc.10"}] + JSON + end + + def test_rejects_an_already_published_version + error = assert_raises(ArgumentError) do + PgqueRelease::RubyGemsVersionCheck.check!("0.3.0.rc.1", fixture("published.json")) + end + assert_match(/already published.*immutable.*new version/, error.message) + end + + def test_rejects_invalid_json + error = assert_raises(ArgumentError) do + PgqueRelease::RubyGemsVersionCheck.check!("0.3.0.rc.2", fixture("invalid.json")) + end + assert_match(/invalid RubyGems versions response/, error.message) + end + + def test_rejects_an_invalid_top_level_schema + error = assert_raises(ArgumentError) do + PgqueRelease::RubyGemsVersionCheck.check!("0.3.0.rc.2", fixture("invalid_schema.json")) + end + assert_match(/expected a JSON array/, error.message) + end + + def test_rejects_an_entry_without_a_version_number + error = assert_raises(ArgumentError) do + PgqueRelease::RubyGemsVersionCheck.check!("0.3.0.rc.2", fixture("missing_number.json")) + end + assert_match(/entry 0 has no string number/, error.message) + end + + def test_cli_rejects_a_published_version + _stdout, stderr, status = Open3.capture3( + RbConfig.ruby, + RUBYGEMS_CHECKER, + "0.3.0.rc.1", + File.join(FIXTURE_DIR, "published.json"), + ) + + refute status.success? + assert_match(/already published.*immutable/, stderr) + end + + def test_accepts_a_zero_skip_minitest_summary + output = "10 runs, 20 assertions, 0 failures, 0 errors, 0 skips\n" + assert PgqueRelease::TestSkipCheck.check!(output) + end + + def test_rejects_a_nonzero_skip_minitest_summary + error = assert_raises(ArgumentError) do + PgqueRelease::TestSkipCheck.check!( + "10 runs, 20 assertions, 0 failures, 0 errors, 2 skips\n", + ) + end + assert_match(/reported 2 skipped test/, error.message) + end + + def test_rejects_output_without_a_minitest_summary + error = assert_raises(ArgumentError) do + PgqueRelease::TestSkipCheck.check!("no tests ran\n") + end + assert_match(/summary not found/, error.message) + end +end