From 2b66303b901fa8de78cc04dc35189c651584290b Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Mon, 12 Oct 2026 20:30:01 +0900 Subject: [PATCH 1/4] ci: add a release workflow for the structured_params-openapi gem - release-openapi.yml publishes the gem when a GitHub Release tagged openapi-vX.Y.Z is published: it sets VERSION, prepends the release notes to structured_params-openapi/CHANGELOG.md, commits to main, builds the gem and runs rubygems/release-gem from structured_params-openapi/ - structured_params-openapi/Rakefile provides build/release tasks that tag releases with the openapi- prefix, so they never collide with the core gem's vX.Y.Z tags - release.yml now ignores openapi-v* releases - add structured_params-openapi/CHANGELOG.md and ship it in the gem - document releasing both gems in CONTRIBUTING.md Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release-openapi.yml | 101 ++++++++++++++++++ .github/workflows/release.yml | 5 +- CONTRIBUTING.md | 19 ++++ structured_params-openapi/CHANGELOG.md | 2 + structured_params-openapi/Rakefile | 11 ++ .../structured_params-openapi.gemspec | 2 + 6 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release-openapi.yml create mode 100644 structured_params-openapi/CHANGELOG.md create mode 100644 structured_params-openapi/Rakefile diff --git a/.github/workflows/release-openapi.yml b/.github/workflows/release-openapi.yml new file mode 100644 index 0000000..8f3dbbc --- /dev/null +++ b/.github/workflows/release-openapi.yml @@ -0,0 +1,101 @@ +name: Release structured_params-openapi + +# Publishes the structured_params-openapi gem when a GitHub Release tagged openapi-vX.Y.Z is +# published. The core gem is released by release.yml from vX.Y.Z tags. +on: + release: + types: [ published ] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + if: startsWith(github.event.release.tag_name, 'openapi-v') + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - name: Configure Git + uses: ./.github/actions/configure-git + + - name: Extract version from release + id: extract_version + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + VERSION=${TAG_NAME#openapi-v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Update version file + env: + VERSION: ${{ steps.extract_version.outputs.version }} + run: | + sed -i "s/VERSION = '[^']*'/VERSION = '$VERSION'/" structured_params-openapi/lib/structured_params/openapi/version.rb + + - name: Update CHANGELOG + env: + VERSION: ${{ steps.extract_version.outputs.version }} + TAG_NAME: ${{ github.event.release.tag_name }} + RELEASE_NOTES: ${{ github.event.release.body }} + run: | + CHANGELOG=structured_params-openapi/CHANGELOG.md + CURRENT_DATE=$(date +%Y-%m-%d) + + # Create temporary changelog entry + echo "## [$VERSION] - $CURRENT_DATE" > temp_changelog.md + echo "" >> temp_changelog.md + + # Add release notes from GitHub release + if [ -n "$RELEASE_NOTES" ]; then + echo "$RELEASE_NOTES" >> temp_changelog.md + else + echo "- Release $TAG_NAME" >> temp_changelog.md + fi + echo "" >> temp_changelog.md + + # Prepend to existing CHANGELOG (skip the header) + if [ -f "$CHANGELOG" ]; then + tail -n +3 "$CHANGELOG" >> temp_changelog.md + fi + + # Add header back + echo "# Changelog" > "$CHANGELOG" + echo "" >> "$CHANGELOG" + cat temp_changelog.md >> "$CHANGELOG" + + rm -f temp_changelog.md + + - name: Commit version and CHANGELOG + env: + VERSION: ${{ steps.extract_version.outputs.version }} + run: | + git add structured_params-openapi/lib/structured_params/openapi/version.rb structured_params-openapi/CHANGELOG.md + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Update structured_params-openapi to $VERSION" + git push origin main + fi + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Build gem + working-directory: structured_params-openapi + run: | + gem build structured_params-openapi.gemspec + + # `rake release` finds the Release's tag (openapi-vX.Y.Z), so it only builds and pushes the gem. + - name: Publish to RubyGems + uses: rubygems/release-gem@v1 + with: + working-directory: structured_params-openapi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cb613b..44c1942 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,11 @@ jobs: permissions: contents: write id-token: write # IMPORTANT: this permission is mandatory for trusted publishing - # Only run if the workflow_run was successful or if triggered by release event + # Only run if the workflow_run was successful or if triggered by a core release (tag vX.Y.Z). + # structured_params-openapi releases (tag openapi-vX.Y.Z) are handled by release-openapi.yml. if: | (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || - (github.event_name == 'release') + (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v')) steps: - name: Checkout code diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e76026f..ce237e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,6 +206,25 @@ RAILS_VERSION="~> 8.0.0" bundle update && bundle exec rspec └── structured_params-openapi/ # OpenAPI drift check gem (same layout) ``` +## Releasing + +The repository holds two gems that are released independently, each from a GitHub Release. + +| Gem | Release tag | Workflow | Version file | +|-----|-------------|----------|--------------| +| `structured_params` | `vX.Y.Z` | `tagging.yml` + `release.yml` | `structured_params/lib/structured_params/version.rb` | +| `structured_params-openapi` | `openapi-vX.Y.Z` | `release-openapi.yml` | `structured_params-openapi/lib/structured_params/openapi/version.rb` | + +To release `structured_params-openapi`: + +1. Make sure `main` contains the changes to ship and CI is green. +2. Create a GitHub Release with a new tag `openapi-vX.Y.Z` targeting `main`, and write the release notes. +3. Publishing the Release runs `release-openapi.yml`, which sets `VERSION` and prepends the release notes to `structured_params-openapi/CHANGELOG.md` (one commit to `main`), then builds the gem and publishes it to RubyGems through Trusted Publishing. + +The `openapi-` prefix keeps the tags apart: `release.yml` ignores `openapi-v*` releases and `tagging.yml` only reacts to `v*` tags. + +One-time setup for a new gem: register a (pending) Trusted Publisher on rubygems.org for `structured_params-openapi` with repository `Syati/structured_params` and workflow `release-openapi.yml`. + ## Submitting Changes ### Pull Request Process diff --git a/structured_params-openapi/CHANGELOG.md b/structured_params-openapi/CHANGELOG.md new file mode 100644 index 0000000..4dc68c6 --- /dev/null +++ b/structured_params-openapi/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/structured_params-openapi/Rakefile b/structured_params-openapi/Rakefile new file mode 100644 index 0000000..2033a21 --- /dev/null +++ b/structured_params-openapi/Rakefile @@ -0,0 +1,11 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +# Release tasks (`rake build`, `rake release`) for this gem; run them from this directory. +# The repository-wide checks (specs, RuboCop, Steep) live in the root Rakefile. +require 'bundler/gem_helper' + +# Tag releases openapi-vX.Y.Z so they never collide with the core gem's vX.Y.Z tags. +gem_helper = Bundler::GemHelper.new +gem_helper.tag_prefix = 'openapi-' +gem_helper.install diff --git a/structured_params-openapi/structured_params-openapi.gemspec b/structured_params-openapi/structured_params-openapi.gemspec index cc78c25..b074a39 100644 --- a/structured_params-openapi/structured_params-openapi.gemspec +++ b/structured_params-openapi/structured_params-openapi.gemspec @@ -22,6 +22,7 @@ Gem::Specification.new do |spec| spec.files = Dir[ 'LICENSE.txt', 'README*.md', + 'CHANGELOG.md', 'docs/**/*.md', 'lib/**/*.rb', 'lib/tasks/**/*.rake', @@ -30,6 +31,7 @@ Gem::Specification.new do |spec| spec.metadata = { 'homepage_uri' => 'https://github.com/Syati/structured_params/tree/main/structured_params-openapi#readme', + 'changelog_uri' => 'https://github.com/Syati/structured_params/blob/main/structured_params-openapi/CHANGELOG.md', 'bug_tracker_uri' => 'https://github.com/Syati/structured_params/issues', 'source_code_uri' => 'https://github.com/Syati/structured_params/tree/main/structured_params-openapi', 'rubygems_mfa_required' => 'true' From b0f7f389ee99d2819da2f548aa332b48c22d0f43 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Mon, 12 Oct 2026 20:41:02 +0900 Subject: [PATCH 2/4] ci: release the core gem from a single job and drop tagging.yml tagging.yml (version bump on a v* tag) and release.yml (CHANGELOG, build, publish on a published Release) ran concurrently: release.yml's checkout of main had to land after the version commit, which for v0.9.5 happened within the same second. The workflow_run trigger meant to order them never fired (its branches: [main] filter does not match a tag push). Merge them into one sequential job, as release-openapi.yml does: set VERSION, prepend the release notes to structured_params/CHANGELOG.md, commit once to main, then build and publish. Pass untrusted release text through env: instead of interpolating it into the script. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 65 +++++++++++++++++++---------------- .github/workflows/tagging.yml | 45 ------------------------ CONTRIBUTING.md | 6 ++-- 3 files changed, 38 insertions(+), 78 deletions(-) delete mode 100644 .github/workflows/tagging.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44c1942..e6706fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,24 +1,18 @@ name: Release and Publish +# Publishes the structured_params gem when a GitHub Release tagged vX.Y.Z is published. +# The structured_params-openapi gem is released by release-openapi.yml from openapi-vX.Y.Z tags. on: release: types: [ published ] - workflow_run: - workflows: ["Update Version"] - types: [completed] - branches: [main] jobs: - update-changelog-and-publish: + release: runs-on: ubuntu-latest permissions: contents: write id-token: write # IMPORTANT: this permission is mandatory for trusted publishing - # Only run if the workflow_run was successful or if triggered by a core release (tag vX.Y.Z). - # structured_params-openapi releases (tag openapi-vX.Y.Z) are handled by release-openapi.yml. - if: | - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || - (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v')) + if: startsWith(github.event.release.tag_name, 'v') steps: - name: Checkout code @@ -32,50 +26,60 @@ jobs: - name: Extract version from release id: extract_version + env: + TAG_NAME: ${{ github.event.release.tag_name }} run: | - TAG_NAME=${{ github.event.release.tag_name }} VERSION=${TAG_NAME#v} echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + + - name: Update version file + env: + VERSION: ${{ steps.extract_version.outputs.version }} + run: | + sed -i "s/VERSION = '[^']*'/VERSION = '$VERSION'/" structured_params/lib/structured_params/version.rb - name: Update CHANGELOG + env: + VERSION: ${{ steps.extract_version.outputs.version }} + TAG_NAME: ${{ github.event.release.tag_name }} + RELEASE_NOTES: ${{ github.event.release.body }} run: | - VERSION="${{ steps.extract_version.outputs.version }}" + CHANGELOG=structured_params/CHANGELOG.md CURRENT_DATE=$(date +%Y-%m-%d) - RELEASE_NOTES="${{ github.event.release.body }}" - + # Create temporary changelog entry echo "## [$VERSION] - $CURRENT_DATE" > temp_changelog.md echo "" >> temp_changelog.md - + # Add release notes from GitHub release if [ -n "$RELEASE_NOTES" ]; then echo "$RELEASE_NOTES" >> temp_changelog.md else - echo "- Release ${{ steps.extract_version.outputs.tag_name }}" >> temp_changelog.md + echo "- Release $TAG_NAME" >> temp_changelog.md fi echo "" >> temp_changelog.md - + # Prepend to existing CHANGELOG (skip the header) - if [ -f structured_params/CHANGELOG.md ]; then - tail -n +3 structured_params/CHANGELOG.md >> temp_changelog.md + if [ -f "$CHANGELOG" ]; then + tail -n +3 "$CHANGELOG" >> temp_changelog.md fi - + # Add header back - echo "# Changelog" > structured_params/CHANGELOG.md - echo "" >> structured_params/CHANGELOG.md - cat temp_changelog.md >> structured_params/CHANGELOG.md - - # Clean up temporary file + echo "# Changelog" > "$CHANGELOG" + echo "" >> "$CHANGELOG" + cat temp_changelog.md >> "$CHANGELOG" + rm -f temp_changelog.md - - name: Commit CHANGELOG update + - name: Commit version and CHANGELOG + env: + VERSION: ${{ steps.extract_version.outputs.version }} run: | - git add structured_params/CHANGELOG.md + git add structured_params/lib/structured_params/version.rb structured_params/CHANGELOG.md if git diff --staged --quiet; then - echo "No CHANGELOG changes to commit" + echo "No changes to commit" else - git commit -m "Update CHANGELOG for ${{ steps.extract_version.outputs.tag_name }}" + git commit -m "Update structured_params to $VERSION" git push origin main fi @@ -90,6 +94,7 @@ jobs: run: | gem build structured_params.gemspec + # `rake release` finds the Release's tag (vX.Y.Z), so it only builds and pushes the gem. - name: Publish to RubyGems uses: rubygems/release-gem@v1 with: diff --git a/.github/workflows/tagging.yml b/.github/workflows/tagging.yml deleted file mode 100644 index 45f9057..0000000 --- a/.github/workflows/tagging.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Update Version - -on: - push: - tags: - - 'v*' - -jobs: - update-version: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - ref: main - - - name: Configure Git - uses: ./.github/actions/configure-git - - - name: Extract version from tag - id: extract_version - run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - - - name: Update version file - run: | - VERSION="${{ steps.extract_version.outputs.version }}" - sed -i "s/VERSION = '[^']*'/VERSION = '$VERSION'/" structured_params/lib/structured_params/version.rb - - - name: Commit version update - run: | - git add structured_params/lib/structured_params/version.rb - if git diff --staged --quiet; then - echo "No version changes to commit" - else - git commit -m "Update version to ${{ steps.extract_version.outputs.version }}" - git push origin main - fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce237e6..9728e69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -212,16 +212,16 @@ The repository holds two gems that are released independently, each from a GitHu | Gem | Release tag | Workflow | Version file | |-----|-------------|----------|--------------| -| `structured_params` | `vX.Y.Z` | `tagging.yml` + `release.yml` | `structured_params/lib/structured_params/version.rb` | +| `structured_params` | `vX.Y.Z` | `release.yml` | `structured_params/lib/structured_params/version.rb` | | `structured_params-openapi` | `openapi-vX.Y.Z` | `release-openapi.yml` | `structured_params-openapi/lib/structured_params/openapi/version.rb` | -To release `structured_params-openapi`: +Both follow the same steps. To release `structured_params-openapi`: 1. Make sure `main` contains the changes to ship and CI is green. 2. Create a GitHub Release with a new tag `openapi-vX.Y.Z` targeting `main`, and write the release notes. 3. Publishing the Release runs `release-openapi.yml`, which sets `VERSION` and prepends the release notes to `structured_params-openapi/CHANGELOG.md` (one commit to `main`), then builds the gem and publishes it to RubyGems through Trusted Publishing. -The `openapi-` prefix keeps the tags apart: `release.yml` ignores `openapi-v*` releases and `tagging.yml` only reacts to `v*` tags. +The `openapi-` prefix keeps the tags apart: `release.yml` only handles tags starting with `v` and `release-openapi.yml` only `openapi-v` tags. To release the core gem, use a tag `vX.Y.Z` the same way. One-time setup for a new gem: register a (pending) Trusted Publisher on rubygems.org for `structured_params-openapi` with repository `Syati/structured_params` and workflow `release-openapi.yml`. From e83754e3b60caffc0f7a6f3284436b60ef85624c Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Mon, 12 Oct 2026 21:15:48 +0900 Subject: [PATCH 3/4] ci: make the release workflows validate, idempotent and race-free Address the review of the single-job release workflows: - validate the Release tag (vX.Y.Z / openapi-vX.Y.Z), that its commit is on main and that the version does not go backwards; assert that the VERSION substitution applied - skip the CHANGELOG entry when the version is already there, so a re-run does not duplicate it; normalize CRLF in the release notes - build the gem before pushing to main, so a failure leaves main untouched - ignore pre-releases and share a concurrency group between the two workflows - document releasing in CONTRIBUTING.md (what CI writes, re-runs, Trusted Publisher setup, the tag pointing before the version commit) and fix stale paths in AGENTS.md and copilot-instructions.md Co-Authored-By: Claude Sonnet 5.5 --- .github/copilot-instructions.md | 6 ++-- .github/workflows/release-openapi.yml | 50 +++++++++++++++++++++++---- .github/workflows/release.yml | 50 +++++++++++++++++++++++---- AGENTS.md | 2 +- CONTRIBUTING.md | 25 +++++++++----- 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ecec1f9..6620f67 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -71,7 +71,7 @@ Style/RbsInline/MissingTypeAnnotation: ### RBS Signature Generation -**DO NOT** manually edit files in `sig/` directory. These files are auto-generated from inline annotations. +**DO NOT** manually edit files in the `sig/` directory of each gem (`structured_params/sig/`, `structured_params-openapi/sig/`). These files are auto-generated from inline annotations. To generate RBS signature files: @@ -107,5 +107,5 @@ This gem supports both Strong Parameters validation and Form Object pattern: - Use RSpec for testing - Group tests by context (e.g., "API context", "Form Object context") -- Test files are in `spec/` directory -- Support files (test helper classes) are in `spec/support/` +- Test files are in each gem's `spec/` directory (`structured_params/spec/`, `structured_params-openapi/spec/`) +- Support files (test helper classes) are in `structured_params/spec/support/` diff --git a/.github/workflows/release-openapi.yml b/.github/workflows/release-openapi.yml index 8f3dbbc..41694cf 100644 --- a/.github/workflows/release-openapi.yml +++ b/.github/workflows/release-openapi.yml @@ -6,13 +6,19 @@ on: release: types: [ published ] +# Both release workflows commit to main, so never run two at once. +concurrency: + group: release-main + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest permissions: contents: write id-token: write # IMPORTANT: this permission is mandatory for trusted publishing - if: startsWith(github.event.release.tag_name, 'openapi-v') + # Pre-releases are not published to RubyGems by this workflow. + if: startsWith(github.event.release.tag_name, 'openapi-v') && !github.event.release.prerelease steps: - name: Checkout code @@ -24,12 +30,28 @@ jobs: - name: Configure Git uses: ./.github/actions/configure-git - - name: Extract version from release + - name: Extract and validate the version id: extract_version env: TAG_NAME: ${{ github.event.release.tag_name }} run: | VERSION=${TAG_NAME#openapi-v} + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Release tag '$TAG_NAME' must look like openapi-vX.Y.Z" + exit 1 + fi + + # The tagged commit must be on main, and the version must not go backwards + # (re-running an old release must not downgrade version.rb). + if ! git merge-base --is-ancestor "$GITHUB_SHA" HEAD; then + echo "::error::The tagged commit is not on main" + exit 1 + fi + CURRENT=$(sed -n "s/.*VERSION = '\([^']*\)'.*/\1/p" structured_params-openapi/lib/structured_params/openapi/version.rb) + if [ "$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; then + echo "::error::Version $VERSION is older than the current $CURRENT" + exit 1 + fi echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Update version file @@ -37,6 +59,7 @@ jobs: VERSION: ${{ steps.extract_version.outputs.version }} run: | sed -i "s/VERSION = '[^']*'/VERSION = '$VERSION'/" structured_params-openapi/lib/structured_params/openapi/version.rb + grep -qF "VERSION = '$VERSION'" structured_params-openapi/lib/structured_params/openapi/version.rb || { echo "::error::VERSION was not updated"; exit 1; } - name: Update CHANGELOG env: @@ -45,15 +68,21 @@ jobs: RELEASE_NOTES: ${{ github.event.release.body }} run: | CHANGELOG=structured_params-openapi/CHANGELOG.md + # A re-run after a failed publish must not add the entry twice. + if grep -q "^## \[$VERSION\]" "$CHANGELOG"; then + echo "CHANGELOG already has $VERSION" + exit 0 + fi CURRENT_DATE=$(date +%Y-%m-%d) + NOTES=$(printf '%s' "$RELEASE_NOTES" | tr -d '\r') # Create temporary changelog entry echo "## [$VERSION] - $CURRENT_DATE" > temp_changelog.md echo "" >> temp_changelog.md # Add release notes from GitHub release - if [ -n "$RELEASE_NOTES" ]; then - echo "$RELEASE_NOTES" >> temp_changelog.md + if [ -n "$NOTES" ]; then + printf '%s\n' "$NOTES" >> temp_changelog.md else echo "- Release $TAG_NAME" >> temp_changelog.md fi @@ -71,6 +100,8 @@ jobs: rm -f temp_changelog.md + # Committed here, pushed only after the gem has been built, so a failure + # before that point leaves main untouched. - name: Commit version and CHANGELOG env: VERSION: ${{ steps.extract_version.outputs.version }} @@ -80,7 +111,6 @@ jobs: echo "No changes to commit" else git commit -m "Update structured_params-openapi to $VERSION" - git push origin main fi - name: Set up Ruby @@ -91,10 +121,18 @@ jobs: - name: Build gem working-directory: structured_params-openapi + env: + VERSION: ${{ steps.extract_version.outputs.version }} run: | gem build structured_params-openapi.gemspec + test -f "structured_params-openapi-$VERSION.gem" + + - name: Push the version and CHANGELOG to main + run: git push origin main - # `rake release` finds the Release's tag (openapi-vX.Y.Z), so it only builds and pushes the gem. + # `rake release` (see structured_params-openapi/Rakefile) tags releases openapi-vX.Y.Z. The Release already + # created that tag, so only the build and the RubyGems push happen here. That relies on the + # tag being exactly openapi-v plus the VERSION written above, which the validation guarantees. - name: Publish to RubyGems uses: rubygems/release-gem@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6706fd..4ee9509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,19 @@ on: release: types: [ published ] +# Both release workflows commit to main, so never run two at once. +concurrency: + group: release-main + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest permissions: contents: write id-token: write # IMPORTANT: this permission is mandatory for trusted publishing - if: startsWith(github.event.release.tag_name, 'v') + # Pre-releases are not published to RubyGems by this workflow. + if: startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease steps: - name: Checkout code @@ -24,12 +30,28 @@ jobs: - name: Configure Git uses: ./.github/actions/configure-git - - name: Extract version from release + - name: Extract and validate the version id: extract_version env: TAG_NAME: ${{ github.event.release.tag_name }} run: | VERSION=${TAG_NAME#v} + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Release tag '$TAG_NAME' must look like vX.Y.Z" + exit 1 + fi + + # The tagged commit must be on main, and the version must not go backwards + # (re-running an old release must not downgrade version.rb). + if ! git merge-base --is-ancestor "$GITHUB_SHA" HEAD; then + echo "::error::The tagged commit is not on main" + exit 1 + fi + CURRENT=$(sed -n "s/.*VERSION = '\([^']*\)'.*/\1/p" structured_params/lib/structured_params/version.rb) + if [ "$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; then + echo "::error::Version $VERSION is older than the current $CURRENT" + exit 1 + fi echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Update version file @@ -37,6 +59,7 @@ jobs: VERSION: ${{ steps.extract_version.outputs.version }} run: | sed -i "s/VERSION = '[^']*'/VERSION = '$VERSION'/" structured_params/lib/structured_params/version.rb + grep -qF "VERSION = '$VERSION'" structured_params/lib/structured_params/version.rb || { echo "::error::VERSION was not updated"; exit 1; } - name: Update CHANGELOG env: @@ -45,15 +68,21 @@ jobs: RELEASE_NOTES: ${{ github.event.release.body }} run: | CHANGELOG=structured_params/CHANGELOG.md + # A re-run after a failed publish must not add the entry twice. + if grep -q "^## \[$VERSION\]" "$CHANGELOG"; then + echo "CHANGELOG already has $VERSION" + exit 0 + fi CURRENT_DATE=$(date +%Y-%m-%d) + NOTES=$(printf '%s' "$RELEASE_NOTES" | tr -d '\r') # Create temporary changelog entry echo "## [$VERSION] - $CURRENT_DATE" > temp_changelog.md echo "" >> temp_changelog.md # Add release notes from GitHub release - if [ -n "$RELEASE_NOTES" ]; then - echo "$RELEASE_NOTES" >> temp_changelog.md + if [ -n "$NOTES" ]; then + printf '%s\n' "$NOTES" >> temp_changelog.md else echo "- Release $TAG_NAME" >> temp_changelog.md fi @@ -71,6 +100,8 @@ jobs: rm -f temp_changelog.md + # Committed here, pushed only after the gem has been built, so a failure + # before that point leaves main untouched. - name: Commit version and CHANGELOG env: VERSION: ${{ steps.extract_version.outputs.version }} @@ -80,7 +111,6 @@ jobs: echo "No changes to commit" else git commit -m "Update structured_params to $VERSION" - git push origin main fi - name: Set up Ruby @@ -91,10 +121,18 @@ jobs: - name: Build gem working-directory: structured_params + env: + VERSION: ${{ steps.extract_version.outputs.version }} run: | gem build structured_params.gemspec + test -f "structured_params-$VERSION.gem" + + - name: Push the version and CHANGELOG to main + run: git push origin main - # `rake release` finds the Release's tag (vX.Y.Z), so it only builds and pushes the gem. + # `rake release` (see structured_params/Rakefile) tags releases vX.Y.Z. The Release already + # created that tag, so only the build and the RubyGems push happen here. That relies on the + # tag being exactly v plus the VERSION written above, which the validation guarantees. - name: Publish to RubyGems uses: rubygems/release-gem@v1 with: diff --git a/AGENTS.md b/AGENTS.md index 72c9328..5984995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ bin/setup 通常の確認: ```bash -bundle exec rspec +bundle exec rake spec # 両方の gem の spec(各 gem のフォルダで bundle exec rspec でも可) bundle exec rubocop bundle exec steep check ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9728e69..90f7fdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -187,6 +187,7 @@ RAILS_VERSION="~> 8.0.0" bundle update && bundle exec rspec . # repository root: README, Gemfile, Rakefile, Steepfile, lefthook.yml, RuboCop config ├── structured_params/ # core gem │ ├── structured_params.gemspec +│ ├── Rakefile # build/release tasks for this gem │ ├── CHANGELOG.md │ ├── lib/ │ │ ├── structured_params.rb # Main entry point @@ -203,27 +204,35 @@ RAILS_VERSION="~> 8.0.0" bundle update && bundle exec rspec │ │ ├── support/ # Test helpers │ │ └── *_spec.rb # Test files │ └── docs/ # Documentation -└── structured_params-openapi/ # OpenAPI drift check gem (same layout) +└── structured_params-openapi/ # OpenAPI drift check gem (same layout: gemspec, Rakefile, CHANGELOG.md, lib/, sig/, spec/, docs/) ``` ## Releasing -The repository holds two gems that are released independently, each from a GitHub Release. +The repository holds two gems, each released from a GitHub Release with its own tag. The core gem is released first when a change needs both (the openapi gem depends on `structured_params >= 0.9.5`; raise that bound if it needs newer core behaviour). | Gem | Release tag | Workflow | Version file | |-----|-------------|----------|--------------| | `structured_params` | `vX.Y.Z` | `release.yml` | `structured_params/lib/structured_params/version.rb` | | `structured_params-openapi` | `openapi-vX.Y.Z` | `release-openapi.yml` | `structured_params-openapi/lib/structured_params/openapi/version.rb` | -Both follow the same steps. To release `structured_params-openapi`: +To release a gem: -1. Make sure `main` contains the changes to ship and CI is green. -2. Create a GitHub Release with a new tag `openapi-vX.Y.Z` targeting `main`, and write the release notes. -3. Publishing the Release runs `release-openapi.yml`, which sets `VERSION` and prepends the release notes to `structured_params-openapi/CHANGELOG.md` (one commit to `main`), then builds the gem and publishes it to RubyGems through Trusted Publishing. +1. Make sure `main` contains the changes to ship and CI is green. **Do not edit `version.rb` or `CHANGELOG.md` yourself**; the workflow writes both. +2. Create a GitHub Release (published, not a draft or pre-release) with a new tag from the table, targeting `main`, and write the release notes. Pushing a tag alone does nothing. +3. Publishing the Release runs the workflow, which: + - checks that the tag looks like `vX.Y.Z` / `openapi-vX.Y.Z`, that its commit is on `main`, and that the version does not go backwards; + - sets `VERSION`, prepends the release notes to the gem's `CHANGELOG.md` and commits both; + - builds the gem, then pushes that commit to `main`, then publishes the gem to RubyGems through Trusted Publishing. -The `openapi-` prefix keeps the tags apart: `release.yml` only handles tags starting with `v` and `release-openapi.yml` only `openapi-v` tags. To release the core gem, use a tag `vX.Y.Z` the same way. +Things to know: -One-time setup for a new gem: register a (pending) Trusted Publisher on rubygems.org for `structured_params-openapi` with repository `Syati/structured_params` and workflow `release-openapi.yml`. +- The Release's tag points at the commit *before* the version commit, and the gem is built from `main` plus that commit. The tag therefore does not contain the bumped `version.rb`. +- If a run fails, re-run it: the steps are idempotent (the CHANGELOG entry is not added twice and an already pushed commit is a no-op). A gem that was already published to RubyGems cannot be published again, so a failure after publishing needs a new version. +- Pre-releases are ignored by the workflows, and a tag that matches neither `v*` nor `openapi-v*` releases nothing. If `main` has branch protection, the `github-actions` bot must be allowed to push to it. +- The two workflows share a `concurrency` group because both push to `main`; keep the tag prefixes consistent in three places: the workflow `if:` and tag parsing, and `tag_prefix` in the gem's `Rakefile`. + +One-time setup for a new gem: on rubygems.org, register a (pending) Trusted Publisher for `structured_params-openapi` with owner `Syati`, repository `structured_params`, workflow filename `release-openapi.yml` and no environment. The publisher is bound to the workflow's file name, so do not rename `release.yml` or `release-openapi.yml` without updating it. The first publish of a new gem name consumes the pending publisher. ## Submitting Changes From d93e37320aeb6226b05e24b046ee5a324313a42b Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Mon, 12 Oct 2026 21:18:47 +0900 Subject: [PATCH 4/4] ci: point the release tag at the commit that is published A Release creates its tag before the workflow commits the version and CHANGELOG, so the tag did not contain what was built and published. After pushing that commit to main, move the Release's tag onto it (before publishing, so a refused tag push fails the job with nothing released). rake release then finds the tag and only builds and pushes the gem. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release-openapi.yml | 16 +++++++++++++--- .github/workflows/release.yml | 16 +++++++++++++--- CONTRIBUTING.md | 4 ++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-openapi.yml b/.github/workflows/release-openapi.yml index 41694cf..68b3518 100644 --- a/.github/workflows/release-openapi.yml +++ b/.github/workflows/release-openapi.yml @@ -130,9 +130,19 @@ jobs: - name: Push the version and CHANGELOG to main run: git push origin main - # `rake release` (see structured_params-openapi/Rakefile) tags releases openapi-vX.Y.Z. The Release already - # created that tag, so only the build and the RubyGems push happen here. That relies on the - # tag being exactly openapi-v plus the VERSION written above, which the validation guarantees. + # The Release created its tag before the version commit. Point the tag at the commit that is + # built and published, so the tag reproduces the gem. `rake release` then finds the tag and + # skips tagging. Done before publishing so a refused tag push fails the job with nothing released. + - name: Point the release tag at the published commit + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + git tag -f "$TAG_NAME" HEAD + git push --force origin "refs/tags/$TAG_NAME" + + # `rake release` (see structured_params-openapi/Rakefile) tags releases openapi-vX.Y.Z. The tag exists (it was moved + # above), so only the build and the RubyGems push happen here. That relies on the tag being + # exactly openapi-v plus the VERSION written above, which the validation guarantees. - name: Publish to RubyGems uses: rubygems/release-gem@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ee9509..b0014f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,9 +130,19 @@ jobs: - name: Push the version and CHANGELOG to main run: git push origin main - # `rake release` (see structured_params/Rakefile) tags releases vX.Y.Z. The Release already - # created that tag, so only the build and the RubyGems push happen here. That relies on the - # tag being exactly v plus the VERSION written above, which the validation guarantees. + # The Release created its tag before the version commit. Point the tag at the commit that is + # built and published, so the tag reproduces the gem. `rake release` then finds the tag and + # skips tagging. Done before publishing so a refused tag push fails the job with nothing released. + - name: Point the release tag at the published commit + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + git tag -f "$TAG_NAME" HEAD + git push --force origin "refs/tags/$TAG_NAME" + + # `rake release` (see structured_params/Rakefile) tags releases vX.Y.Z. The tag exists (it was moved + # above), so only the build and the RubyGems push happen here. That relies on the tag being + # exactly v plus the VERSION written above, which the validation guarantees. - name: Publish to RubyGems uses: rubygems/release-gem@v1 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90f7fdb..fd46c1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -223,11 +223,11 @@ To release a gem: 3. Publishing the Release runs the workflow, which: - checks that the tag looks like `vX.Y.Z` / `openapi-vX.Y.Z`, that its commit is on `main`, and that the version does not go backwards; - sets `VERSION`, prepends the release notes to the gem's `CHANGELOG.md` and commits both; - - builds the gem, then pushes that commit to `main`, then publishes the gem to RubyGems through Trusted Publishing. + - builds the gem, pushes that commit to `main`, moves the Release's tag onto it, then publishes the gem to RubyGems through Trusted Publishing. Things to know: -- The Release's tag points at the commit *before* the version commit, and the gem is built from `main` plus that commit. The tag therefore does not contain the bumped `version.rb`. +- The Release creates its tag first, then the workflow commits the version and CHANGELOG and **force-moves the tag onto that commit**, so the tag contains exactly what is published. This needs permission to update tags (tag protection rules that forbid it would fail the job before anything is published). - If a run fails, re-run it: the steps are idempotent (the CHANGELOG entry is not added twice and an already pushed commit is a no-op). A gem that was already published to RubyGems cannot be published again, so a failure after publishing needs a new version. - Pre-releases are ignored by the workflows, and a tag that matches neither `v*` nor `openapi-v*` releases nothing. If `main` has branch protection, the `github-actions` bot must be allowed to push to it. - The two workflows share a `concurrency` group because both push to `main`; keep the tag prefixes consistent in three places: the workflow `if:` and tag parsing, and `tag_prefix` in the gem's `Rakefile`.