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
81 changes: 79 additions & 2 deletions .github/workflows/release-ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +50,7 @@ jobs:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
submodules: recursive

- uses: ruby/setup-ruby@v1
with:
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down
38 changes: 24 additions & 14 deletions clients/ruby/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion clients/ruby/lib/pgque/version.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions clients/ruby/script/assert_no_test_skips.rb
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions clients/ruby/script/check_rubygems_version.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions clients/ruby/test/fixtures/rubygems_versions/available.json
Original file line number Diff line number Diff line change
@@ -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
}
]
1 change: 1 addition & 0 deletions clients/ruby/test/fixtures/rubygems_versions/invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"number":
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"number": "0.3.0.rc.1"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"authors": "PgQue maintainers",
"prerelease": true
}
]
8 changes: 8 additions & 0 deletions clients/ruby/test/fixtures/rubygems_versions/published.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
{
"authors": "PgQue maintainers",
"built_at": "2026-07-09T00:00:00.000Z",
"number": "0.3.0.rc.1",
"prerelease": true
}
]
Loading
Loading