diff --git a/.github/actions/release-smoke-package/action.yml b/.github/actions/release-smoke-package/action.yml new file mode 100644 index 00000000..766a5cca --- /dev/null +++ b/.github/actions/release-smoke-package/action.yml @@ -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 + ' diff --git a/.github/scripts/release_verify.rb b/.github/scripts/release_verify.rb new file mode 100644 index 00000000..17a8c3c4 --- /dev/null +++ b/.github/scripts/release_verify.rb @@ -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+\[(?[^\]]+)\](?:\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__ diff --git a/.github/workflows/build-gems.yml b/.github/workflows/build-gems.yml index 8b976d59..67b7baa8 100644 --- a/.github/workflows/build-gems.yml +++ b/.github/workflows/build-gems.yml @@ -4,6 +4,8 @@ on: branches: - main - "releases/*" + workflow_dispatch: {} + workflow_call: {} permissions: contents: read diff --git a/.github/workflows/gems-publish.yml b/.github/workflows/gems-publish.yml index 813a720b..a2b28fec 100644 --- a/.github/workflows/gems-publish.yml +++ b/.github/workflows/gems-publish.yml @@ -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 @@ -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=). 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 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 3f051b83..17d9504b 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1,5 +1,24 @@ name: Release Publish -run-name: Release Publish (stub) +run-name: Release from ${{ github.ref_name }} + +# Single-dispatch release pipeline mirroring sdk-python's release-publish.yml. +# +# On one workflow_dispatch, the pipeline: +# build → cross-compile all platform gems + source gem +# verify → validate version/changelog, assemble release-dist artifact +# publish → push to rubygems.org (gated by the `rubygems` GitHub +# Environment: required reviewer clicks Approve here) +# smoke_published → install from rubygems.org and run a workflow +# create_draft → open a draft GitHub Release with the changelog notes +# +# SAFETY: `gem push` lives only in gems-publish.yml, and gems-publish.yml +# is workflow_call-only (cannot be dispatched, run on push, or scheduled). +# The publish job here calls it with `environment: rubygems`; the +# environment's required-reviewers gate + deployment-branch policy is the +# human-in-the-loop. To rehearse this pipeline without publishing, dispatch +# it and simply do not approve the environment prompt — the build+verify +# jobs run first and their results are visible; nothing pushes until an +# approver clicks Approve. on: workflow_dispatch: {} @@ -7,14 +26,172 @@ on: permissions: contents: read +concurrency: + group: release-publish-${{ github.ref }} + cancel-in-progress: false + jobs: - placeholder: - name: placeholder + build: + name: Build all gems + uses: ./.github/workflows/build-gems.yml + permissions: + contents: read + actions: write + + verify: + name: Verify release artifacts + needs: build runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: read + outputs: + version: ${{ steps.validate.outputs.version }} + head_sha: ${{ steps.validate.outputs.sha }} + artifact_name: release-dist-${{ steps.validate.outputs.version }} steps: - - name: Explain + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: oxidize-rb/actions/setup-ruby-and-rust@e5f9a49a7812a078584072f6e3f657ad247c8771 # v1 + with: + ruby-version: "3.4" + bundler-cache: false + cargo-cache: false + + - name: Validate checked-in version + id: validate + run: | + set -euo pipefail + ruby .github/scripts/release_verify.rb validate-version \ + --sha "$(git rev-parse HEAD)" \ + --github-output "$GITHUB_OUTPUT" + + - name: Extract changelog release notes + env: + VERSION: ${{ steps.validate.outputs.version }} + run: | + set -euo pipefail + ruby .github/scripts/release_verify.rb changelog-notes \ + --version "$VERSION" \ + --output release-notes.md + + - name: Download platform + source gem artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir artifacts dist + gh run download "$GITHUB_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --dir artifacts \ + --pattern '*-gem' + + while IFS= read -r -d '' file; do + dest="dist/$(basename "$file")" + if [[ -e "$dest" ]]; then + echo "Duplicate gem filename across artifacts: $(basename "$file")" >&2 + exit 1 + fi + cp "$file" "$dest" + done < <(find artifacts -type f -name '*.gem' -print0) + + - name: Verify dist set + env: + VERSION: ${{ steps.validate.outputs.version }} + run: | + set -euo pipefail + ruby .github/scripts/release_verify.rb verify-dist \ + --version "$VERSION" \ + --dist dist + + - name: Upload consolidated release-dist artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-dist-${{ steps.validate.outputs.version }} + path: dist + if-no-files-found: error + retention-days: 14 + + - name: Upload release notes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-notes-${{ steps.validate.outputs.version }} + path: release-notes.md + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish to rubygems.org + needs: verify + uses: ./.github/workflows/gems-publish.yml + permissions: + contents: read + id-token: write + with: + environment: rubygems + version: ${{ needs.verify.outputs.version }} + artifact-name: ${{ needs.verify.outputs.artifact_name }} + host: https://rubygems.org + + smoke_published: + name: Smoke test rubygems.org package + needs: + - verify + - publish + strategy: + fail-fast: false + # Subset of build-gems.yml's smoke matrix — enough coverage after + # publish without re-running the full grid the build already tested. + matrix: + include: + - os: ubuntu-latest + rubyVersion: "3.3" + - os: ubuntu-latest + rubyVersion: "4.0" + - os: macos-latest + rubyVersion: "3.4" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/release-smoke-package + with: + version: ${{ needs.verify.outputs.version }} + host: https://rubygems.org + ruby-version: ${{ matrix.rubyVersion }} + + create_draft_release: + name: Create draft GitHub Release + needs: + - verify + - smoke_published + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.verify.outputs.head_sha }} + fetch-depth: 0 + + - name: Download release-notes artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-notes-${{ needs.verify.outputs.version }} + + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.verify.outputs.head_sha }} + VERSION: ${{ needs.verify.outputs.version }} run: | - cat <<'EOF' - This is a stub release-publish.yml. - EOF - exit 1 + set -euo pipefail + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$RELEASE_SHA" \ + --title "v$VERSION" \ + --draft \ + --notes-file release-notes.md \ + --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index dd478451..ce684fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ to docs, or any other relevant information. ### Added — new features ### Changed — changes in existing functionality ### Deprecated — soon-to-be-removed features -### Breaking Changes — removed or backwards-incompatible features +### :boom: Breaking Changes — removed or backwards-incompatible features ### Fixed — notable bug fixes ### Security — notable security fixes --> @@ -21,6 +21,20 @@ to docs, or any other relevant information. ### Added +### Changed + +### Deprecated + +### :boom: Breaking Changes + +### Fixed + +### Security + +## [v1.6.1.test.only.rc1] - 2026-08-04 + +### Added + - Added the `Temporalio::Worker` `max_eager_activity_reservations_per_workflow_task:` option to configure the number of activity slots reserved for eager execution per workflow task. Values must be positive; use `disable_eager_activity_execution: true` to disable eager execution. @@ -117,7 +131,7 @@ Fixed a race in `replay_workflow` where if a NDE was hit, worker shutdown could ## [v1.5.0] - 2026-06-11 -### Breaking Changes +### :boom: Breaking Changes #### `Activity::Info` workflow fields are now nullable diff --git a/scripts/prepare_release.rb b/scripts/prepare_release.rb new file mode 100644 index 00000000..d3b046ea --- /dev/null +++ b/scripts/prepare_release.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +# Prepare checked-in files for a Ruby SDK release. +# +# Bumps Temporalio::VERSION, rolls the CHANGELOG's [Unreleased] section +# into a dated [vVERSION] section (re-seeding a fresh [Unreleased]), +# refreshes Gemfile.lock, and — unless --skip-git is passed — creates a +# chore/release-VERSION branch off origin/main, commits the release files, +# pushes, and opens the release PR via `gh`. +# +# Mirrors sdk-python's scripts/prepare_release.py. + +require 'date' +require 'optparse' +require 'pathname' +require 'set' + +module PrepareRelease + REPO_ROOT = Pathname.new(__dir__).parent.expand_path + + CHANGELOG_HEADERS = [ + 'Added', + 'Changed', + 'Deprecated', + ':boom: Breaking Changes', + 'Fixed', + 'Security' + ].freeze + + VERSION_RE = /\A[0-9]+(?:\.[0-9]+)+[A-Za-z0-9_.+\-]*\z/.freeze + CHANGELOG_HEADING_RE = /\A##\s+\[(?[^\]]+)\](?:\s+-\s+.*)?\s*\z/.freeze + CHANGELOG_SUBHEADING_RE = /\A###\s+(?
.+?)\s*\z/.freeze + + RELEASE_FILES = [ + 'CHANGELOG.md', + 'temporalio/Gemfile.lock', + 'temporalio/lib/temporalio/version.rb' + ].freeze + + module_function + + def validate_version(version) + raise ArgumentError, "Invalid version #{version.inspect}; expected '1.30.0'-style" unless VERSION_RE.match?(version) + + version + end + + def parse_date(str) + Date.iso8601(str) + rescue ArgumentError + raise ArgumentError, "Invalid release date #{str.inspect}; expected YYYY-MM-DD" + end + + # Replace `VERSION = '...'` in lib/temporalio/version.rb. Preserves the + # quote style already in the file. + def replace_version_constant(text, version) + validate_version(version) + updated = text.sub(/^(\s*VERSION\s*=\s*)(['"])[^'"]+\2/) do + "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{version}#{Regexp.last_match(2)}" + end + raise 'Could not find VERSION constant' if updated == text + + updated + end + + # Roll [Unreleased] into a dated [vVERSION] section, re-seed a fresh + # empty [Unreleased] above it. Fails if [Unreleased] is empty, if a + # section for the target version already exists, or if [Unreleased] is + # missing entirely. + def finalize_changelog_release(text, version:, release_date:) + validate_version(version) + heading = "[v#{version}]" + + lines = text.split("\n", -1) + trailing_newline = lines.pop == '' # split with -1 keeps a trailing '' for text ending in \n + + raise "Changelog already has a section for #{heading}" if find_version_section(lines, "v#{version}") + + unreleased = find_version_section(lines, 'Unreleased') + raise "Could not find changelog section for 'Unreleased'" unless unreleased + + heading_index, section_start, section_end = unreleased + body = strip_empty_changelog_headers(strip_outer_blank_lines(lines[section_start...section_end])) + raise "Changelog section for 'Unreleased' is empty" if body.empty? + + result = lines[0...heading_index] + + seeded_unreleased_lines + + ["## #{heading} - #{release_date.iso8601}", ''] + + body + + [''] + + lines[section_end..] + output = collapse_blank_lines(result).join("\n").rstrip + output + (trailing_newline ? "\n" : '') + end + + def seeded_unreleased_lines + lines = ['## [Unreleased]', ''] + CHANGELOG_HEADERS.each { |h| lines.push("### #{h}", '') } + lines + end + + def find_version_section(lines, version) + lines.each_with_index do |line, index| + match = CHANGELOG_HEADING_RE.match(line) + next unless match && match[:version] == version + + section_end = lines.length + ((index + 1)...lines.length).each do |end_index| + if lines[end_index].start_with?('## ') + section_end = end_index + break + end + end + return [index, index + 1, section_end] + end + nil + end + + def strip_outer_blank_lines(lines) + result = lines.dup + result.shift while result.first && result.first.strip.empty? + result.pop while result.last && result.last.strip.empty? + result + end + + def strip_empty_changelog_headers(lines) + filtered = [] + index = 0 + while index < lines.length + match = CHANGELOG_SUBHEADING_RE.match(lines[index]) + unless match && CHANGELOG_HEADERS.include?(match[:header]) + filtered << lines[index] + index += 1 + next + end + + next_index = index + 1 + next_index += 1 while next_index < lines.length && !lines[next_index].start_with?('### ') + + body = lines[(index + 1)...next_index] + if body.any? { |l| !l.strip.empty? } + filtered << lines[index] + filtered.concat(body) + end + index = next_index + end + strip_outer_blank_lines(filtered) + end + + def collapse_blank_lines(lines) + collapsed = [] + previous_blank = false + lines.each do |line| + blank = line.strip.empty? + next if blank && previous_blank + + collapsed << line + previous_blank = blank + end + collapsed + end + + # --- git / gh side effects ------------------------------------------------- + + def run(cmd, cwd: REPO_ROOT, check: true) + system(*cmd, chdir: cwd.to_s, exception: check) + end + + def capture(cmd, cwd: REPO_ROOT) + require 'open3' + stdout, status = Open3.capture2(*cmd, chdir: cwd.to_s) + raise "Command failed (#{status.exitstatus}): #{cmd.join(' ')}" unless status.success? + + stdout + end + + def changed_files(cwd: REPO_ROOT) + capture(%w[git status --porcelain], cwd: cwd).lines(chomp: true).map { |line| line[3..] }.to_set + end + + def ensure_clean_worktree(cwd: REPO_ROOT) + changes = changed_files(cwd: cwd) + return if changes.empty? + + raise "Release preparation requires a clean worktree; found changes in #{changes.to_a.sort.join(', ')}" + end + + def ensure_only_release_changes(cwd: REPO_ROOT) + unexpected = changed_files(cwd: cwd) - RELEASE_FILES + return if unexpected.empty? + + raise "Release preparation changed unexpected files: #{unexpected.to_a.sort.join(', ')}" + end + + def branch_name(version) + "chore/release-#{version}" + end + + def create_release_branch(version, base_ref: 'origin/main', cwd: REPO_ROOT) + branch = base_ref.sub(%r{\Aorigin/}, '') + raise "base_ref must be an 'origin/...' ref, got #{base_ref.inspect}" if branch == base_ref + + run(['git', 'fetch', 'origin', branch], cwd: cwd) + run(['git', 'switch', '--create', branch_name(version), base_ref], cwd: cwd) + end + + def commit_release_changes(version, cwd: REPO_ROOT) + run(['git', 'commit', '-m', "Prepare release #{version}", '--', *RELEASE_FILES], cwd: cwd) + end + + def push_release_branch(version, cwd: REPO_ROOT) + run(['git', 'push', '--set-upstream', 'origin', branch_name(version)], cwd: cwd) + end + + def create_release_pr(version, cwd: REPO_ROOT) + run( + ['gh', 'pr', 'create', + '--base', 'main', + '--head', branch_name(version), + '--title', "Prepare release #{version}", + '--body', "Prepare release #{version}."], + cwd: cwd + ) + end + + # --- main ------------------------------------------------------------------ + + def main(argv) + options = { + date: Date.today.iso8601, + skip_lock: false, + skip_git: false, + base_ref: 'origin/main' + } + parser = OptionParser.new do |o| + o.banner = 'Usage: prepare_release.rb VERSION [options]' + o.on('--date DATE', 'Release date in YYYY-MM-DD (default: today)') { |v| options[:date] = v } + o.on('--base-ref REF', 'Git ref to branch the release from (default: origin/main)') { |v| options[:base_ref] = v } + o.on('--skip-lock', 'Skip refreshing Gemfile.lock (local testing only)') { options[:skip_lock] = true } + o.on('--skip-git', 'Skip branch/commit/push/PR (local testing only)') { options[:skip_git] = true } + end + positional = parser.parse(argv) + if positional.length != 1 + warn parser.help + exit 2 + end + + version = validate_version(positional.first) + release_date = parse_date(options[:date]) + + ensure_clean_worktree unless options[:skip_git] + create_release_branch(version, base_ref: options[:base_ref]) unless options[:skip_git] + + changelog_path = REPO_ROOT.join('CHANGELOG.md') + version_path = REPO_ROOT.join('temporalio', 'lib', 'temporalio', 'version.rb') + + changelog_path.write( + finalize_changelog_release(changelog_path.read, version: version, release_date: release_date) + ) + version_path.write(replace_version_constant(version_path.read, version)) + + unless options[:skip_lock] + run(%w[bundle lock], cwd: REPO_ROOT.join('temporalio')) + end + + unless options[:skip_git] + ensure_only_release_changes + commit_release_changes(version) + push_release_branch(version) + create_release_pr(version) + end + + puts "Prepared release #{version} dated #{release_date.iso8601}#{options[:skip_git] ? '' : ' and opened a PR'}" + end +end + +PrepareRelease.main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/scripts/rollback_release.rb b/scripts/rollback_release.rb new file mode 100644 index 00000000..bb9ae15a --- /dev/null +++ b/scripts/rollback_release.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +# Roll back an in-flight release. +# +# Given a version, this: +# 1. Cancels the specified Release Publish workflow run (auto-discovered +# from `gh run list` if --run-id is not provided). +# 2. Finds the "Prepare release VERSION" commit on origin/main. +# 3. Creates chore/rollback-VERSION off origin/main, `git revert`s the +# prep commit (with -m 1 if it's a merge commit), pushes, and opens +# a revert PR via `gh`. +# +# The revert PR is reviewed and merged through the normal PR flow. Once +# merged, main is back to pre-release state and the release can be retried +# by running scripts/prepare_release.rb again. +# +# Intended use: rehearse a full release, hit the rubygems env gate, decide +# not to approve, and roll back cleanly with `ruby scripts/rollback_release.rb VERSION`. + +require 'json' +require 'open3' +require 'optparse' +require 'pathname' + +module RollbackRelease + REPO_ROOT = Pathname.new(__dir__).parent.expand_path + + VERSION_RE = /\A[0-9]+(?:\.[0-9]+)+[A-Za-z0-9_.+\-]*\z/.freeze + + # Statuses that indicate a workflow run has not yet reached a terminal + # state — the env-gate `waiting` state is the interesting one for our + # rehearsal use case. + ACTIVE_RUN_STATUSES = %w[in_progress queued waiting requested pending].freeze + + module_function + + def validate_version(version) + raise ArgumentError, "Invalid version #{version.inspect}; expected '1.30.0'-style" unless VERSION_RE.match?(version) + + version + end + + def prep_branch_name(version) + "chore/release-#{version}" + end + + def rollback_branch_name(version) + "chore/rollback-#{version}" + end + + def prep_commit_subject(version) + "Prepare release #{version}" + end + + # --- subprocess wrappers (stubbed in tests) -------------------------------- + + def run(cmd, cwd: REPO_ROOT, check: true) + system(*cmd, chdir: cwd.to_s, exception: check) + end + + def capture(cmd, cwd: REPO_ROOT) + stdout, status = Open3.capture2(*cmd, chdir: cwd.to_s) + raise "Command failed (#{status.exitstatus}): #{cmd.join(' ')}" unless status.success? + + stdout + end + + # --- workflow-run discovery + cancel --------------------------------------- + + # Return the databaseId of the most recent non-terminal Release Publish + # run on `main`, or nil if none is pending. + def discover_pending_run(cwd: REPO_ROOT) + output = capture( + [ + 'gh', 'run', 'list', + '--workflow', 'release-publish.yml', + '--branch', 'main', + '--limit', '10', + '--json', 'databaseId,status' + ], + cwd: cwd + ) + JSON.parse(output).each do |entry| + return entry['databaseId'].to_s if ACTIVE_RUN_STATUSES.include?(entry['status']) + end + nil + end + + def cancel_run(run_id, cwd: REPO_ROOT) + # `check: false` — if the run has already ended between discovery and + # this call, `gh run cancel` will complain; that's fine. + run(['gh', 'run', 'cancel', run_id.to_s], cwd: cwd, check: false) + end + + # --- commit discovery + revert --------------------------------------------- + + # SHA on origin/main whose subject equals "Prepare release VERSION", + # or nil if not found in the recent history. + def find_prep_commit(version, cwd: REPO_ROOT) + subject = prep_commit_subject(version) + output = capture(['git', 'log', 'origin/main', '--format=%H %s', '-50'], cwd: cwd) + output.each_line do |line| + sha, _, msg = line.chomp.partition(' ') + return sha if msg == subject + end + nil + end + + # Number of parents of the given commit. 1 = normal / squash-merged, + # 2+ = merge commit (needs `-m 1` on revert). + def parent_count(sha, cwd: REPO_ROOT) + output = capture(['git', 'rev-list', '--parents', '-n', '1', sha], cwd: cwd) + output.chomp.split.length - 1 + end + + # --- git / gh side effects ------------------------------------------------- + + def create_rollback_branch(version, cwd: REPO_ROOT) + run(%w[git fetch origin main], cwd: cwd) + run(['git', 'switch', '--create', rollback_branch_name(version), 'origin/main'], cwd: cwd) + end + + def revert_prep_commit(sha, cwd: REPO_ROOT) + args = ['git', 'revert', '--no-edit'] + args += ['-m', '1'] if parent_count(sha, cwd: cwd) >= 2 + args << sha + run(args, cwd: cwd) + end + + def push_rollback_branch(version, cwd: REPO_ROOT) + run(['git', 'push', '--set-upstream', 'origin', rollback_branch_name(version)], cwd: cwd) + end + + def create_rollback_pr(version, cwd: REPO_ROOT) + run( + [ + 'gh', 'pr', 'create', + '--base', 'main', + '--head', rollback_branch_name(version), + '--title', "Revert release #{version}", + '--body', "Reverts \"Prepare release #{version}\". Release rehearsal aborted before publish." + ], + cwd: cwd + ) + end + + # --- main ------------------------------------------------------------------ + + def main(argv) + options = { run_id: nil, skip_cancel: false, skip_revert: false, dry_run: false } + parser = OptionParser.new do |o| + o.banner = 'Usage: rollback_release.rb VERSION [options]' + o.on('--run-id ID', 'Specific workflow run to cancel (default: auto-discover pending run)') { |v| options[:run_id] = v } + o.on('--skip-cancel', 'Do not cancel any workflow runs') { options[:skip_cancel] = true } + o.on('--skip-revert', 'Do not revert the prep commit or open a PR') { options[:skip_revert] = true } + o.on('--dry-run', 'Print planned actions; make no changes') { options[:dry_run] = true } + end + positional = parser.parse(argv) + if positional.length != 1 + warn parser.help + exit 2 + end + + version = validate_version(positional.first) + + unless options[:skip_cancel] + run_id = options[:run_id] || discover_pending_run + if run_id + puts "Cancelling workflow run #{run_id}" + cancel_run(run_id) unless options[:dry_run] + else + puts 'No pending Release Publish run found; nothing to cancel' + end + end + + return if options[:skip_revert] + + run(%w[git fetch origin main]) unless options[:dry_run] + sha = find_prep_commit(version) + unless sha + warn "No \"#{prep_commit_subject(version)}\" commit found on origin/main." + warn 'If the prep PR was never merged, close it and delete the branch manually.' + exit 1 + end + + puts "Reverting #{sha[0, 8]} (\"#{prep_commit_subject(version)}\")" + if options[:dry_run] + puts " would create branch: #{rollback_branch_name(version)}" + puts ' would run: git revert' + (parent_count(sha) >= 2 ? ' -m 1 ' : ' ') + sha + puts ' would push branch and open a "Revert release" PR' + return + end + + create_rollback_branch(version) + revert_prep_commit(sha) + push_rollback_branch(version) + create_rollback_pr(version) + puts "Rollback PR opened. Review and merge it to restore main." + end +end + +RollbackRelease.main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/temporalio/lib/temporalio/version.rb b/temporalio/lib/temporalio/version.rb index fb5201bf..f2451943 100644 --- a/temporalio/lib/temporalio/version.rb +++ b/temporalio/lib/temporalio/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Temporalio - VERSION = '1.6.0' + VERSION = '1.6.1.test.only.rc1' end diff --git a/test/test_prepare_release.rb b/test/test_prepare_release.rb new file mode 100644 index 00000000..e2f9abec --- /dev/null +++ b/test/test_prepare_release.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +# Unit tests for scripts/prepare_release.rb. +# +# Runs as a plain minitest file — no dependency on the gem's own test +# harness or bundler. Invoke with `ruby test/test_prepare_release.rb`. + +require 'date' +require 'minitest/autorun' +require 'minitest/mock' +require 'pathname' +require 'set' + +require_relative '../scripts/prepare_release' + +class TestPrepareRelease < Minitest::Test + def test_validate_version_accepts_semver_shapes + assert_equal '1.6.0', PrepareRelease.validate_version('1.6.0') + assert_equal '1.30.0', PrepareRelease.validate_version('1.30.0') + assert_equal '1.6.0.rc1', PrepareRelease.validate_version('1.6.0.rc1') + assert_equal '1.6.0-rc1', PrepareRelease.validate_version('1.6.0-rc1') + end + + def test_validate_version_rejects_v_prefix + assert_raises(ArgumentError) { PrepareRelease.validate_version('v1.6.0') } + end + + def test_validate_version_rejects_garbage + assert_raises(ArgumentError) { PrepareRelease.validate_version('') } + assert_raises(ArgumentError) { PrepareRelease.validate_version('1') } + assert_raises(ArgumentError) { PrepareRelease.validate_version('abc') } + end + + def test_parse_date_accepts_iso + assert_equal Date.new(2026, 8, 1), PrepareRelease.parse_date('2026-08-01') + end + + def test_parse_date_rejects_non_iso + assert_raises(ArgumentError) { PrepareRelease.parse_date('August 1, 2026') } + end + + def test_replace_version_constant_single_quoted + text = <<~RB + # frozen_string_literal: true + + module Temporalio + VERSION = '1.6.0' + end + RB + updated = PrepareRelease.replace_version_constant(text, '1.6.1') + assert_includes updated, "VERSION = '1.6.1'" + refute_includes updated, "'1.6.0'" + end + + def test_replace_version_constant_double_quoted_preserves_quotes + text = "module Temporalio\n VERSION = \"1.6.0\"\nend\n" + updated = PrepareRelease.replace_version_constant(text, '1.6.1') + assert_includes updated, 'VERSION = "1.6.1"' + end + + def test_replace_version_constant_raises_when_missing + assert_raises(RuntimeError) do + PrepareRelease.replace_version_constant("module Temporalio\nend\n", '1.6.1') + end + end + + def test_finalize_changelog_release_rolls_unreleased_into_dated_section + text = <<~MD + # Changelog + + ## [Unreleased] + + ### Added + + - New feature X. + + ### Fixed + + - Fixed bug Y. + + ## [v1.5.0] - 2026-06-11 + + ### Added + + - Prior release note. + MD + updated = PrepareRelease.finalize_changelog_release( + text, version: '1.6.0', release_date: Date.new(2026, 8, 1) + ) + + # New [Unreleased] block is present, with all headers seeded empty. + assert_match(/^## \[Unreleased\]$/, updated) + PrepareRelease::CHANGELOG_HEADERS.each { |h| assert_includes updated, "### #{h}" } + + # Dated release section with v prefix contains only the non-empty + # sections we had populated. + assert_match(/^## \[v1\.6\.0\] - 2026-08-01$/, updated) + assert_includes updated, '- New feature X.' + assert_includes updated, '- Fixed bug Y.' + + # Prior release section untouched. + assert_includes updated, '## [v1.5.0] - 2026-06-11' + assert_includes updated, '- Prior release note.' + + # Only non-empty headers made it into the dated section. + dated_start = updated.index('## [v1.6.0]') + dated_end = updated.index('## [v1.5.0]') + dated_section = updated[dated_start...dated_end] + refute_includes dated_section, '### Deprecated' + refute_includes dated_section, '### Security' + end + + def test_finalize_changelog_release_refuses_empty_unreleased + text = <<~MD + # Changelog + + ## [Unreleased] + + ### Added + + ## [v1.5.0] - 2026-06-11 + MD + assert_raises(RuntimeError) do + PrepareRelease.finalize_changelog_release( + text, version: '1.6.0', release_date: Date.new(2026, 8, 1) + ) + end + end + + def test_finalize_changelog_release_refuses_missing_unreleased + text = "# Changelog\n\n## [v1.5.0] - 2026-06-11\n\n### Added\n- prior\n" + assert_raises(RuntimeError) do + PrepareRelease.finalize_changelog_release( + text, version: '1.6.0', release_date: Date.new(2026, 8, 1) + ) + end + end + + def test_finalize_changelog_release_refuses_duplicate_version_section + text = <<~MD + # Changelog + + ## [Unreleased] + + ### Added + + - something + + ## [v1.6.0] - 2026-06-01 + + - already released + MD + assert_raises(RuntimeError) do + PrepareRelease.finalize_changelog_release( + text, version: '1.6.0', release_date: Date.new(2026, 8, 1) + ) + end + end + + def test_branch_name + assert_equal 'chore/release-1.6.1', PrepareRelease.branch_name('1.6.1') + end + + # --- git / gh side-effect helpers ------------------------------------------ + # Mirrors sdk-python's pattern: stub the subprocess wrapper to record calls + # instead of executing them, then assert on the captured args. + + REPO = Pathname.new('/repo').freeze + + # Run block with PrepareRelease.run stubbed. Yields the calls array; each + # entry is [cmd, cwd, check]. + def with_recorded_run + calls = [] + recorder = lambda do |cmd, cwd: nil, check: true| + calls << [cmd, cwd, check] + nil + end + PrepareRelease.stub(:run, recorder) do + yield calls + end + end + + def test_create_release_branch_fetches_main_and_switches_from_it + with_recorded_run do |calls| + PrepareRelease.create_release_branch('1.6.1', cwd: REPO) + assert_equal( + [ + [%w[git fetch origin main], REPO, true], + [['git', 'switch', '--create', 'chore/release-1.6.1', 'origin/main'], REPO, true] + ], + calls + ) + end + end + + def test_create_release_branch_with_alternate_base_ref + with_recorded_run do |calls| + PrepareRelease.create_release_branch( + '1.6.1', + base_ref: 'origin/gmt/ruby-auto-release', + cwd: REPO + ) + assert_equal( + [ + [%w[git fetch origin gmt/ruby-auto-release], REPO, true], + [['git', 'switch', '--create', 'chore/release-1.6.1', 'origin/gmt/ruby-auto-release'], REPO, true] + ], + calls + ) + end + end + + def test_create_release_branch_rejects_non_origin_base_ref + err = assert_raises(RuntimeError) do + PrepareRelease.create_release_branch('1.6.1', base_ref: 'main', cwd: REPO) + end + assert_match(/origin\//, err.message) + end + + def test_commit_release_changes_commits_only_release_files + with_recorded_run do |calls| + PrepareRelease.commit_release_changes('1.6.1', cwd: REPO) + assert_equal 1, calls.length + assert_equal( + ['git', 'commit', '-m', 'Prepare release 1.6.1', '--', *PrepareRelease::RELEASE_FILES], + calls[0][0] + ) + assert_equal REPO, calls[0][1] + end + end + + def test_push_release_branch_pushes_versioned_branch + with_recorded_run do |calls| + PrepareRelease.push_release_branch('1.6.1', cwd: REPO) + assert_equal( + [[['git', 'push', '--set-upstream', 'origin', 'chore/release-1.6.1'], REPO, true]], + calls + ) + end + end + + def test_create_release_pr_uses_versioned_branch + with_recorded_run do |calls| + PrepareRelease.create_release_pr('1.6.1', cwd: REPO) + assert_equal 1, calls.length + assert_equal( + [ + 'gh', 'pr', 'create', + '--base', 'main', + '--head', 'chore/release-1.6.1', + '--title', 'Prepare release 1.6.1', + '--body', 'Prepare release 1.6.1.' + ], + calls[0][0] + ) + assert_equal REPO, calls[0][1] + end + end + + def test_ensure_clean_worktree_passes_on_clean + PrepareRelease.stub(:changed_files, Set.new) do + PrepareRelease.ensure_clean_worktree(cwd: REPO) # must not raise + end + end + + def test_ensure_clean_worktree_rejects_existing_changes + PrepareRelease.stub(:changed_files, Set.new(['CHANGELOG.md', 'other.rb'])) do + err = assert_raises(RuntimeError) { PrepareRelease.ensure_clean_worktree(cwd: REPO) } + assert_match(/clean worktree/, err.message) + assert_match(/CHANGELOG\.md/, err.message) + assert_match(/other\.rb/, err.message) + end + end + + def test_ensure_only_release_changes_passes_with_only_allowed_files + PrepareRelease.stub(:changed_files, Set.new(PrepareRelease::RELEASE_FILES)) do + PrepareRelease.ensure_only_release_changes(cwd: REPO) # must not raise + end + end + + def test_ensure_only_release_changes_rejects_unexpected_files + dirty = Set.new(PrepareRelease::RELEASE_FILES + ['unrelated.txt']) + PrepareRelease.stub(:changed_files, dirty) do + err = assert_raises(RuntimeError) { PrepareRelease.ensure_only_release_changes(cwd: REPO) } + assert_match(/unexpected files/, err.message) + assert_match(/unrelated\.txt/, err.message) + end + end +end diff --git a/test/test_rollback_release.rb b/test/test_rollback_release.rb new file mode 100644 index 00000000..3ac049b8 --- /dev/null +++ b/test/test_rollback_release.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +# Unit tests for scripts/rollback_release.rb. +# Run with: ruby test/test_rollback_release.rb + +require 'json' +require 'minitest/autorun' +require 'minitest/mock' +require 'pathname' + +require_relative '../scripts/rollback_release' + +class TestRollbackRelease < Minitest::Test + REPO = Pathname.new('/repo').freeze + + # --- name derivations ------------------------------------------------------ + + def test_validate_version_accepts_semver_and_rejects_v_prefix + assert_equal '1.6.1', RollbackRelease.validate_version('1.6.1') + assert_raises(ArgumentError) { RollbackRelease.validate_version('v1.6.1') } + assert_raises(ArgumentError) { RollbackRelease.validate_version('') } + end + + def test_prep_branch_name + assert_equal 'chore/release-1.6.1', RollbackRelease.prep_branch_name('1.6.1') + end + + def test_rollback_branch_name + assert_equal 'chore/rollback-1.6.1', RollbackRelease.rollback_branch_name('1.6.1') + end + + def test_prep_commit_subject + assert_equal 'Prepare release 1.6.1', RollbackRelease.prep_commit_subject('1.6.1') + end + + # --- helpers to stub subprocess wrappers ----------------------------------- + + def with_recorded_run + calls = [] + recorder = lambda do |cmd, cwd: nil, check: true| + calls << [cmd, cwd, check] + nil + end + RollbackRelease.stub(:run, recorder) do + yield calls + end + end + + def with_capture_returning(output_by_cmd_prefix) + faker = lambda do |cmd, cwd: nil| + key = output_by_cmd_prefix.keys.find { |prefix| cmd.take(prefix.length) == prefix } + raise "Unmocked capture call: #{cmd.inspect}" unless key + + output_by_cmd_prefix[key] + end + RollbackRelease.stub(:capture, faker) do + yield + end + end + + # --- workflow-run discovery + cancel --------------------------------------- + + def test_discover_pending_run_returns_first_active_run_id + payload = JSON.generate( + [ + { 'databaseId' => 111, 'status' => 'completed' }, + { 'databaseId' => 222, 'status' => 'waiting' }, + { 'databaseId' => 333, 'status' => 'in_progress' } + ] + ) + with_capture_returning(%w[gh run list] => payload) do + assert_equal '222', RollbackRelease.discover_pending_run(cwd: REPO) + end + end + + def test_discover_pending_run_returns_nil_when_none_active + payload = JSON.generate( + [ + { 'databaseId' => 111, 'status' => 'completed' }, + { 'databaseId' => 112, 'status' => 'completed' } + ] + ) + with_capture_returning(%w[gh run list] => payload) do + assert_nil RollbackRelease.discover_pending_run(cwd: REPO) + end + end + + def test_cancel_run_invokes_gh_run_cancel_with_check_false + with_recorded_run do |calls| + RollbackRelease.cancel_run(999, cwd: REPO) + assert_equal 1, calls.length + assert_equal ['gh', 'run', 'cancel', '999'], calls[0][0] + assert_equal REPO, calls[0][1] + refute calls[0][2], 'cancel_run must pass check: false' + end + end + + # --- commit discovery + revert --------------------------------------------- + + def test_find_prep_commit_matches_exact_subject + log = <<~OUT + cafebabe0 Some other commit + deadbeef1 Prepare release 1.6.1 + abc12300 Prepare release 1.6.0 + OUT + with_capture_returning(%w[git log origin/main] => log) do + assert_equal 'deadbeef1', RollbackRelease.find_prep_commit('1.6.1', cwd: REPO) + end + end + + def test_find_prep_commit_returns_nil_when_missing + with_capture_returning(%w[git log origin/main] => "cafebabe0 Some other commit\n") do + assert_nil RollbackRelease.find_prep_commit('1.6.1', cwd: REPO) + end + end + + def test_parent_count_from_rev_list_output + # 1 parent (normal commit): line has 2 hashes total + with_capture_returning(%w[git rev-list --parents] => "deadbeef1 cafebabe0\n") do + assert_equal 1, RollbackRelease.parent_count('deadbeef1', cwd: REPO) + end + # 2 parents (merge commit): line has 3 hashes total + with_capture_returning(%w[git rev-list --parents] => "deadbeef1 cafebabe0 abc12300\n") do + assert_equal 2, RollbackRelease.parent_count('deadbeef1', cwd: REPO) + end + end + + def test_revert_prep_commit_squash_merge_no_dash_m + # 1 parent → not a merge → no -m 1 + with_capture_returning(%w[git rev-list --parents] => "deadbeef1 cafebabe0\n") do + with_recorded_run do |calls| + RollbackRelease.revert_prep_commit('deadbeef1', cwd: REPO) + assert_equal 1, calls.length + assert_equal(['git', 'revert', '--no-edit', 'deadbeef1'], calls[0][0]) + end + end + end + + def test_revert_prep_commit_true_merge_uses_dash_m_1 + # 2 parents → merge commit → needs -m 1 + with_capture_returning(%w[git rev-list --parents] => "deadbeef1 cafebabe0 abc12300\n") do + with_recorded_run do |calls| + RollbackRelease.revert_prep_commit('deadbeef1', cwd: REPO) + assert_equal(['git', 'revert', '--no-edit', '-m', '1', 'deadbeef1'], calls[0][0]) + end + end + end + + # --- git / gh side effects ------------------------------------------------- + + def test_create_rollback_branch_fetches_and_switches + with_recorded_run do |calls| + RollbackRelease.create_rollback_branch('1.6.1', cwd: REPO) + assert_equal( + [ + [%w[git fetch origin main], REPO, true], + [['git', 'switch', '--create', 'chore/rollback-1.6.1', 'origin/main'], REPO, true] + ], + calls + ) + end + end + + def test_push_rollback_branch + with_recorded_run do |calls| + RollbackRelease.push_rollback_branch('1.6.1', cwd: REPO) + assert_equal( + [[['git', 'push', '--set-upstream', 'origin', 'chore/rollback-1.6.1'], REPO, true]], + calls + ) + end + end + + def test_create_rollback_pr_uses_versioned_branch + with_recorded_run do |calls| + RollbackRelease.create_rollback_pr('1.6.1', cwd: REPO) + assert_equal 1, calls.length + cmd = calls[0][0] + assert_equal 'gh', cmd[0] + assert_equal 'pr', cmd[1] + assert_equal 'create', cmd[2] + assert_includes cmd, 'main' + assert_includes cmd, 'chore/rollback-1.6.1' + assert_includes cmd, 'Revert release 1.6.1' + end + end +end