diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5752b77..88c1ae5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,13 +1,41 @@ name: Ruby -on: [push, pull_request] +on: + push: + branches: [master] + pull_request: + schedule: + # Weekly, matching .github/dependabot.yml: a scheduled run is the only way + # bundler-audit sees an advisory published against a lockfile nobody has + # touched. Only the audit leg runs on this trigger, via the `if` on `test` + # and the matrix in `checks`. The minute is off the hour on purpose: :00 is + # GitHub's busiest slot and runs scheduled there are routinely delayed. + - cron: "17 6 * * 1" + # GitHub disables the cron above after 60 days without repository activity, + # which is exactly the dormant repository the audit is meant to cover. This + # is the manual path, and running it also re-arms the schedule. + workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + # Only PR pushes are worth cancelling. Cancelling a master run would leave + # that commit recorded as cancelled instead of green or red, and would kill + # the run that populates the cache PR runs restore from. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: + # The scheduled run is for the advisory check; the compatibility matrix has + # nothing new to say about a tree nobody has touched. + if: github.event_name != 'schedule' runs-on: ubuntu-latest + # Generous next to the 10-20s a warm run takes, because a dependabot + # lockfile bump misses the bundler cache and installs a full Rails tree + # from source. Still bounds a hang well under the 6-hour default. + timeout-minutes: 30 name: "rspec - Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}" strategy: fail-fast: false @@ -25,7 +53,16 @@ jobs: env: BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile steps: - - uses: actions/checkout@v5 + # actions/checkout is pinned by commit SHA; dependabot bumps the SHA and + # the trailing version comment together. ruby/setup-ruby stays on the + # `v1` tag upstream documents, because a pinned release only knows the + # Ruby builds it shipped with: an added matrix entry (ROADMAP 5.2 plans + # Ruby 4.0) would fail with an unhelpful unknown-version error. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # No step needs git credentials after checkout, and bundler runs + # gemspec and native-extension code from the branch under test. + persist-credentials: false - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -34,11 +71,27 @@ jobs: - name: Run rspec run: bundle exec rspec - lint: + checks: runs-on: ubuntu-latest - name: rubocop + timeout-minutes: 30 + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + # Each leg's `command` is executed by the single step at the end of + # this job, so the `bundle exec ...` lines live here rather than in a + # `run:`. rubocop lints the tree; bundler-audit checks the lockfile + # against the advisory database. A scheduled run drops the rubocop leg + # for the reason given on `test` above, and repeats the audit entry + # because an expression can only pick between whole lists. + include: >- + ${{ github.event_name == 'schedule' + && fromJSON('[{"name": "bundler-audit", "command": "bundle exec bundle-audit check --update"}]') + || fromJSON('[{"name": "rubocop", "command": "bundle exec rubocop"}, {"name": "bundler-audit", "command": "bundle exec bundle-audit check --update"}]') }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -46,19 +99,5 @@ jobs: # gem's own 3.2 floor is covered by the test matrix. ruby-version: "3.4" bundler-cache: true - - name: Run rubocop - run: bundle exec rubocop - - audit: - runs-on: ubuntu-latest - name: bundler-audit - steps: - - uses: actions/checkout@v5 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - # As in the lint job: the root lockfile needs Ruby >= 3.3. - ruby-version: "3.4" - bundler-cache: true - - name: Check the lockfile against the advisory database - run: bundle exec bundle-audit check --update + - name: Run ${{ matrix.name }} + run: ${{ matrix.command }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c8935b0..c7465e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## [Unreleased] +### Changed +- CI pins `actions/checkout` by commit SHA (v7.0.1) instead of the mutable `v7` tag, so a repointed upstream tag cannot change what runs without a diff here. `ruby/setup-ruby` deliberately stays on `v1`, the reference upstream documents, because a pinned release only knows the Ruby builds it shipped with. Checkout also runs with `persist-credentials: false`: no step needs git credentials, and bundler evaluates gemspec and native-extension code from the branch under test. +- The workflow runs once per change rather than twice. `push` is scoped to `master`, so a branch with an open pull request no longer builds under both events, and a `concurrency` group cancels superseded pull request runs. Runs on `master` are left alone, so every commit there keeps a result. +- `rubocop` and `bundler-audit` share one job definition instead of two byte-identical ones, and every job carries `timeout-minutes: 30` in place of the six-hour default. + +### Added +- A weekly `schedule` trigger, so `bundler-audit` reports an advisory published against a lockfile nobody has touched. Only the audit runs on that trigger. `workflow_dispatch` runs it on demand and is also how the cron is re-armed, since GitHub disables scheduled workflows after 60 days without repository activity. + ## [1.6.0] - 2026-09-03 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 8e868f7..d7fece3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -450,11 +450,11 @@ Priority: High. Category: Test debt. Effort: M. Status: Confirmed. Priority: Medium. Category: CI. Effort: M. Status: Confirmed. -**Evidence.** `.github/workflows/main.yml:15-21` runs six Ruby/Rails pairs, but `spec/spec_helper.rb` requires only `rspec/json_api`, which in turn requires a single ActiveSupport file (`json_api.rb:7`). Nothing requires `rails` or `rspec-rails`, and the generators are never invoked. Each Rails job therefore tests `Object#blank?` against that Rails version. The 1.5.0 CHANGELOG describes the matrix as making "the advertised version support actually tested". +**Evidence.** `.github/workflows/main.yml:47-52` runs six Ruby/Rails pairs, but `spec/spec_helper.rb` requires only `rspec/json_api`, which in turn requires a single ActiveSupport file (`json_api.rb:7`). Nothing requires `rails` or `rspec-rails`, and the generators are never invoked. Each Rails job therefore tests `Object#blank?` against that Rails version. The 1.5.0 CHANGELOG describes the matrix as making "the advertised version support actually tested". **Problem and impact.** Six jobs of CI time for one line of coverage, and a false sense that Rails 6.1 through 8.1 compatibility is verified. Ruby 4.0 is also absent even though 1.5.0 shipped a Ruby 4.0 load fix (commit `8f7318a`), and the local lockfile was resolved on Ruby 4.0. -**Recommended solution.** After 2.1, the runtime matrix only needs Ruby versions (3.2, 3.3, 3.4, 4.0) with the plain Gemfile. Keep one or two Rails appraisals that actually load `rspec-rails` and run the generator specs from 5.1 against a minimal dummy app. Add `permissions: contents: read` and a `concurrency` group while touching the file. +**Recommended solution.** After 2.1, the runtime matrix only needs Ruby versions (3.2, 3.3, 3.4, 4.0) with the plain Gemfile. Keep one or two Rails appraisals that actually load `rspec-rails` and run the generator specs from 5.1 against a minimal dummy app. The `permissions: contents: read` and `concurrency` parts of this item are done; only the matrix rework is outstanding. ### 5.3 Supply-chain checks are not automated (done in 1.6.0)