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 new file mode 100644 index 0000000..68b3518 --- /dev/null +++ b/.github/workflows/release-openapi.yml @@ -0,0 +1,149 @@ +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 ] + +# 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 + # 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 + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - name: Configure Git + uses: ./.github/actions/configure-git + + - 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 + env: + 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: + 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 + # 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 "$NOTES" ]; then + printf '%s\n' "$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 + + # 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 }} + 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" + 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 + 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 + + # 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: + working-directory: structured_params-openapi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cb613b..b0014f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,23 +1,24 @@ 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] + +# Both release workflows commit to main, so never run two at once. +concurrency: + group: release-main + cancel-in-progress: false 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 release event - if: | - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || - (github.event_name == 'release') + # 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 @@ -29,53 +30,87 @@ 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: | - TAG_NAME=${{ github.event.release.tag_name }} 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 - 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 + grep -qF "VERSION = '$VERSION'" structured_params/lib/structured_params/version.rb || { echo "::error::VERSION was not updated"; exit 1; } - 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 + # 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) - RELEASE_NOTES="${{ github.event.release.body }}" - + 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 ${{ 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 + # 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 }} 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 push origin main + git commit -m "Update structured_params to $VERSION" fi - name: Set up Ruby @@ -86,9 +121,28 @@ 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 + + # 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/.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 1850818..92da2f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -187,6 +187,7 @@ RAILS_VERSION="~> 8.0.0" bundle update && bundle exec rake spec . # 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,9 +204,36 @@ RAILS_VERSION="~> 8.0.0" bundle update && bundle exec rake spec │ │ ├── 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, 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` | + +To release a gem: + +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, 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 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`. + +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 ### 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'