Skip to content
Closed
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
58 changes: 58 additions & 0 deletions .github/actions/release-smoke-package/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Release package smoke test
description: >
Install a published `temporalio` gem at a specific version from a given
host and run a minimal workflow to verify the install works.

inputs:
version:
description: "Gem version to install."
required: true
host:
description: "Gem host to install from (e.g. https://rubygems.org)."
required: false
default: https://rubygems.org
ruby-version:
description: "Ruby version for the install and smoke test."
required: false
default: "3.4"

runs:
using: composite
steps:
- uses: oxidize-rb/actions/setup-ruby-and-rust@e5f9a49a7812a078584072f6e3f657ad247c8771 # v1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: false
cargo-cache: false

- name: Install gem
shell: bash
env:
VERSION: ${{ inputs.version }}
HOST: ${{ inputs.host }}
run: |
set -euo pipefail
# For musl Ruby the ruby-platform google-protobuf builds fail; the
# existing smoke_test_gem.rb has the same conditional. Kept aligned
# so post-publish behavior matches the pre-publish artifact test.
if [[ "$(ruby -e 'puts RUBY_PLATFORM')" == *linux-musl* ]]; then
gem install --verbose google-protobuf --platform ruby
fi
gem install --verbose --source "$HOST" temporalio -v "$VERSION"

- name: Run workflow smoke test
shell: bash
run: |
set -euo pipefail
ruby -e '
require "temporalio/client"
require "temporalio/testing/workflow_environment"
Temporalio::Testing::WorkflowEnvironment.start_local do |env|
handle = env.client.start_workflow(
"MyWorkflow",
id: "release-smoke-#{Time.now.to_i}",
task_queue: "release-smoke",
)
puts "Started workflow, run ID: #{handle.result_run_id}"
end
'
166 changes: 166 additions & 0 deletions .github/scripts/release_verify.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# frozen_string_literal: true

# Release workflow validation helpers.
#
# Subcommands:
# validate-version [--sha SHA] [--github-output PATH]
# Read Temporalio::VERSION from temporalio/lib/temporalio/version.rb,
# assert it looks like a semver-ish string with no leading 'v',
# and emit `version=...` (and optional `sha=...`) to GITHUB_OUTPUT.
#
# changelog-notes --version VERSION --output PATH [--changelog PATH]
# Extract the `## [VERSION]` section from CHANGELOG.md, prepend a
# "Notable Changes" header, and write to PATH. Fails if the section
# is missing or empty.
#
# verify-dist --version VERSION --dist DIR
# Assert DIR contains exactly the expected set of .gem files for
# VERSION: one source gem plus one gem per platform in the release
# matrix. Fails on duplicates, missing platforms, wrong versions,
# or unexpected files.

require 'optparse'
require 'pathname'

REPO_ROOT = Pathname.new(__dir__).parent.parent.expand_path
VERSION_FILE = REPO_ROOT.join('temporalio', 'lib', 'temporalio', 'version.rb')
DEFAULT_CHANGELOG = REPO_ROOT.join('CHANGELOG.md')

# Platform suffixes that appear on a gem filename: temporalio-VERSION-PLATFORM.gem.
# Kept in sync with the matrix in .github/workflows/build-gems.yml.
EXPECTED_PLATFORMS = %w[
aarch64-linux
aarch64-linux-musl
x86_64-linux
x86_64-linux-musl
arm64-darwin
x86_64-darwin
].freeze

def checked_in_version
source = VERSION_FILE.read
match = source.match(/^\s*VERSION\s*=\s*['"]([^'"]+)['"]/)
raise "Could not find VERSION constant in #{VERSION_FILE}" unless match

version = match[1]
raise "Checked-in version must not start with 'v': #{version.inspect}" if version.start_with?('v')
unless version.match?(/\A[0-9]+(?:\.[0-9]+)+[A-Za-z0-9_.+\-]*\z/)
raise "Invalid checked-in version: #{version.inspect}"
end

version
end

def write_github_output(path, pairs)
File.open(path, 'a') do |file|
pairs.each { |key, value| file.puts("#{key}=#{value}") }
end
end

def cmd_validate_version(args)
opts = { sha: nil, github_output: nil }
OptionParser.new do |o|
o.on('--sha SHA') { |v| opts[:sha] = v }
o.on('--github-output PATH') { |v| opts[:github_output] = v }
end.parse!(args)

version = checked_in_version
if opts[:github_output]
pairs = { 'version' => version }
pairs['sha'] = opts[:sha] if opts[:sha]
write_github_output(opts[:github_output], pairs)
else
puts version
end
end

def cmd_changelog_notes(args)
opts = { version: nil, output: nil, changelog: DEFAULT_CHANGELOG.to_s }
OptionParser.new do |o|
o.on('--version VERSION') { |v| opts[:version] = v }
o.on('--output PATH') { |v| opts[:output] = v }
o.on('--changelog PATH') { |v| opts[:changelog] = v }
end.parse!(args)

raise '--version is required' unless opts[:version]
raise '--output is required' unless opts[:output]

lines = File.readlines(opts[:changelog], chomp: true)
heading = /\A##\s+\[(?<version>[^\]]+)\](?:\s+-\s+.*)?\s*\z/
# sdk-ruby CHANGELOG headings use a 'v' prefix (## [v1.6.0]) even
# though the checked-in Temporalio::VERSION does not. Match either.
wanted = [opts[:version], "v#{opts[:version]}"]

start_index = nil
lines.each_with_index do |line, index|
match = heading.match(line)
next unless match && wanted.include?(match[:version])

start_index = index + 1
break
end

raise "Could not find changelog section for version #{opts[:version].inspect}" unless start_index

end_index = lines.length
(start_index...lines.length).each do |index|
if lines[index].start_with?('## ')
end_index = index
break
end
end

section = lines[start_index...end_index]
section.shift while section.first && section.first.strip.empty?
section.pop while section.last && section.last.strip.empty?

raise "Changelog section for #{opts[:version].inspect} is empty" if section.empty?

File.write(opts[:output], (['## Notable Changes', ''] + section).join("\n") + "\n")
end

def cmd_verify_dist(args)
opts = { version: nil, dist: 'dist' }
OptionParser.new do |o|
o.on('--version VERSION') { |v| opts[:version] = v }
o.on('--dist DIR') { |v| opts[:dist] = v }
end.parse!(args)

raise '--version is required' unless opts[:version]

dist = Pathname.new(opts[:dist])
raise "Dist directory does not exist: #{dist}" unless dist.directory?

files = dist.children.select { |c| c.file? && c.extname == '.gem' }.map(&:basename).map(&:to_s).sort
raise "Duplicate filenames in #{dist}: #{files.inspect}" if files.length != files.uniq.length

expected_source = "temporalio-#{opts[:version]}.gem"
expected_platform = EXPECTED_PLATFORMS.map { |p| "temporalio-#{opts[:version]}-#{p}.gem" }
expected = ([expected_source] + expected_platform).sort

extra = files - expected
missing = expected - files
raise "Unexpected files in dist: #{extra.inspect}" unless extra.empty?
raise "Missing files in dist: #{missing.inspect}" unless missing.empty?

puts "Verified release artifacts for #{opts[:version]}:"
files.each { |name| puts " #{name}" }
end

DISPATCH = {
'validate-version' => method(:cmd_validate_version),
'changelog-notes' => method(:cmd_changelog_notes),
'verify-dist' => method(:cmd_verify_dist)
}.freeze

def main(argv)
subcommand = argv.shift
handler = DISPATCH[subcommand]
unless handler
warn "Usage: #{File.basename($PROGRAM_NAME)} <#{DISPATCH.keys.join('|')}> [options]"
exit 2
end
handler.call(argv)
end

main(ARGV) if $PROGRAM_NAME == __FILE__
2 changes: 2 additions & 0 deletions .github/workflows/build-gems.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
branches:
- main
- "releases/*"
workflow_dispatch: {}
workflow_call: {}

permissions:
contents: read
Expand Down
107 changes: 101 additions & 6 deletions .github/workflows/gems-publish.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
name: Gems Publish
run-name: Gems Publish (stub)

# Reusable publish step: pushes every .gem in the given artifact to
# rubygems.org (or another host) using Trusted Publishing (OIDC), then
# polls until each gem's VERSION is listed on the host.
#
# SAFETY: The `on:` block declares ONLY `workflow_call`. This workflow
# cannot be dispatched, cannot run on push, and cannot run on schedule.
# The only way to invoke it is from another workflow that references it
# with `uses:` and passes an `environment` input. That environment's
# protection rules (required reviewers + deployment-branch policy) are
# the human-in-the-loop gate.

on:
workflow_call:
inputs:
environment:
description: >
GitHub environment to run the push under. Holds the Trusted
Publishing scoping and any required-reviewers gate.
required: true
type: string
version:
description: "Version expected to appear on the host after push."
required: true
type: string
artifact-name:
description: "Run artifact containing the .gem files to publish."
required: true
type: string
host:
description: "Gem host to push to. Defaults to rubygems.org."
required: false
type: string
default: https://rubygems.org
Expand All @@ -22,11 +38,90 @@ permissions:
contents: read

jobs:
placeholder:
name: placeholder
publish:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
permissions:
contents: read
id-token: write # Required for Trusted Publishing OIDC token issuance.
steps:
- name: Download gem artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.artifact-name }}
path: dist

- name: List gems to be published
run: ls -la dist

- uses: oxidize-rb/actions/setup-ruby-and-rust@e5f9a49a7812a078584072f6e3f657ad247c8771 # v1
with:
ruby-version: "3.4"
bundler-cache: false
cargo-cache: false

# Requests a short-lived RubyGems API key via GitHub OIDC and
# configures env vars for subsequent `gem push` calls. The trust
# relationship must be registered on rubygems.org against
# (repo=temporalio/sdk-ruby, workflow=gems-publish.yml,
# environment=<inputs.environment>). No long-lived key is stored.
- name: RubyGems login (Trusted Publishing / OIDC)
uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0

- name: Push gems
env:
HOST: ${{ inputs.host }}
run: |
set -euo pipefail
shopt -s nullglob
gems=(dist/*.gem)
if [[ ${#gems[@]} -eq 0 ]]; then
echo "No .gem files found in dist/" >&2
exit 1
fi
for gem in "${gems[@]}"; do
echo "Pushing $gem to $HOST"
gem push --host "$HOST" "$gem"
done

wait:
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Explain
# Poll rubygems.org's compact-index — the same endpoint that
# `gem install` uses to enumerate versions. Waiting on this
# endpoint (rather than the JSON /api/v1/versions/... endpoint)
# avoids a race where the JSON API reports the version but the
# compact-index cache hasn't propagated yet, causing downstream
# `gem install` to fail with "Could not find a valid gem".
#
# Note: hardcodes index.rubygems.org — this workflow only ever
# publishes to rubygems.org. If we ever needed to support a
# different host, the index-host derivation would need to change.
- name: Wait for version in compact-index
env:
VERSION: ${{ inputs.version }}
run: |
echo "Stub gems-publish.yml on main."
exit 1
set -euo pipefail
deadline=$(( $(date +%s) + 1500 )) # 25 minutes
# Escape dots so grep treats VERSION as a literal string;
# match the version at start-of-line followed by whitespace
# (source gem) or `-` (platform-suffixed variant).
version_re="^${VERSION//./\\.}[ -]"
url="https://index.rubygems.org/info/temporalio"
while true; do
body=$(curl -fsSL "$url" 2>/dev/null || true)
if echo "$body" | grep -qE "$version_re"; then
echo "temporalio $VERSION visible in compact-index"
exit 0
fi
if [[ $(date +%s) -ge $deadline ]]; then
echo "Timed out waiting for temporalio $VERSION in compact-index" >&2
exit 1
fi
echo "Not yet in compact-index; sleeping 15s..."
sleep 15
done
Loading
Loading