ci(build): halve CI wall clock by parallelizing jobs and caching gems - #380
Conversation
|
Coverage Impact This PR will not change total coverage. 🚦 See full report on Qlty Cloud »🛟 Help
|
2723c42 to
1c78eb1
Compare
christophebrun-forest
left a comment
There was a problem hiding this comment.
The mirror-image dead weight survives in the test job.
Install dependencies on main repo (build.yml:100) is dead weight there for exactly the reason the lint one was: the Test step runs entirely under BUNDLE_GEMFILE=Gemfile-test, all 13 Gemfile-test files declare their own rspec / simplecov / simplecov_json_formatter, and nothing else in the job touches root gems (Wait for MongoDB uses nc, Upload coverage reads the artifact).
From the snowflake job log it costs 3.7s per cell (12:49:46.67 → 12:49:50.33), so ~1min30 of runner time across the 24 cells, and it puts ~12MB of never-loaded gems into every one of the 24 caches. Dropping the step would also make the Gemfile and root *.gemspec components of the test cache key redundant.
Two notes in the PR's favour while I was checking:
bundle exec rubocop --list-target-filesat the root already includes everypackages/*/Gemfileandpackages/*/*.gemspec— RuboCop's defaultIncludecovers**/Gemfile. So the accepted regression is narrower than the description states: package Gemfile syntax is still checked, only dependency resolvability is lost. Worth saying, it makes the trade more clearly fine.required_status_checks.contextsonmainis indeed[], so collapsing the 26Lint (x, y)contexts cannot wedge PRs.
christophebrun-forest
left a comment
There was a problem hiding this comment.
Juste on comment on tests.
Note :
A comment regarding consistency rather than performance: consider adding a group for pull requests specifically to stop them if a new commit is made to the same PR.
|
Both taken, thanks — the two changes are in the working tree and land with the next push. The Concurrency group added, same shape and placement as the one already in concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}Conditional rather than plain On your Gemfile note — confirmed and folded into the description, which was too pessimistic. |
The lint matrix ran the same command 26 times: a single root .rubocop.yml governs the whole monorepo, so one `bundle exec rubocop` already lints all 840 files across the 13 packages. The `packages` dimension only changed which package bundle got installed, and `bundle exec` at the root restricts activation to the root Gemfile, so rubocop never saw those gems in the first place. TargetRubyVersion pins the parser, so the `ruby-version` dimension couldn't change the result either. Collapse it to a single job and drop the package install and unixODBC steps. Verified against a pristine BUNDLE_PATH holding the root Gemfile alone: rails, sqlite3, mongoid and activerecord are absent from the load path, and rubocop still exits 0 over all 840 files. Test no longer needs lint: the two are independent, and chaining them made the critical path the sum of both rather than the max. Deploy now lists lint explicitly, since coverage alone would let a release ship with RuboCop red. Cache the shared BUNDLE_PATH between runs, keyed on the Gemfiles and gemspecs rather than the gitignored lockfiles. The test key names only its own package's Gemfile-test, so a one-package PR doesn't miss on all 24 cells; it keeps every gemspec, since path deps mean a sibling's gemspec still affects resolution. Update the CLAUDE.md scaffolding checklist, which pointed at the lint matrix this commit deletes. 50 jobs -> 27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test job's `Install dependencies on main repo` was dead for the same reason the lint one was: the Test step runs entirely under `BUNDLE_GEMFILE=Gemfile-test`, every matrix package declares its own rspec and simplecov there, and no other step in the job loads a root gem. Verified against a BUNDLE_PATH the root Gemfile never touched — Gemfile-test alone resolves and the suite passes. It cost 3.7s per cell (~1min30 over the 24) and put ~12MB of never-loaded gems into each of the 24 caches. The root Gemfile and gemspec then no longer affect anything the job installs, so they leave the cache key too. `cancel-in-progress` is conditional rather than true because `deploy` pushes gems one package at a time from main/beta; cancelling such a run mid-way would leave a release half-published. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ba983ba to
b5d29b4
Compare
One conflict, in .github/workflows/build.yml, resolved in favour of main. #380 replaced the lint job's per-package matrix with a single job running one root `bundle exec rubocop`, so the third place this branch registered the package no longer exists. Its two other entries auto-merged and are kept: the test matrix and the "Send coverage" files list, 13 packages each. Nothing is lost by dropping the lint entry -- the root .rubocop.yml has no Include, so one rubocop run already walks packages/**, which is what main's CLAUDE.md now says to rely on. That same CLAUDE.md edit fixes the step-5 instruction this branch had followed, so the guidance and the workflow agree again. Verified on the merged tree: toolkit 485, customizer 703, pylon 815, agent 1179, active_record 203 examples, 0 failures; rubocop 911 files, 0 offense. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…369) Adds the forest_admin_datasource_pylon package: Issue, Account, Contact, User and Team collections over the Pylon API, cursor pagination, per-endpoint throttling on top of the 429 retry, custom-fields introspection, CRUD writes, and the CloseIssue / CreateIssueWithNotification action plugins. Also fixes two toolkit bugs found on the way: Max aggregation returned the minimum on every in-memory aggregation, and a value chart over an empty selection was a 500 where the figure is zero. Gem publication checked before merge, all five registration points of CLAUDE.md are covered: * version.rb in the sed-compatible format (double quotes, no .freeze) * gemspec opts out of RubyGems MFA * .rubocop.yml: RequireMFA, MutableConstant, StringLiterals, plus the metric excludes the package actually trips * .releaserc.js: prepareCmd, successCmd and the git assets * build.yml: test matrix and the coverage files: entry (two entries, the lint job being the single root RuboCop run since #380) A grep for the two most recently added packages confirms these are the only registration points in the repo. The CI key is known to create new gem names (hasura and mambu_payments were published that way) and forest_admin_datasource_pylon is unclaimed on RubyGems, so the first release creates it. Known and out of scope, tracked on Linear: the gemspec declares no Forest gem as a runtime dependency (EXT-20, every datasource has it), .releaserc.js chains successCmd with ';' so a failed push is reported as a success (EXT-19), and a non-positive page limit reads two ways (EXT-21). CI green on Ruby 3.4 and 4.0, qlty check and coverage pass, branch up to date with main. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🎉 This PR is included in version 1.41.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |

Why
CI grew slow as datasources were added: 50 jobs, ~52 min of runner time, ~3 min 10 wall clock. Per-job breakdown from run
33058967442showed no slow job — the cost was job count plus uncachedbundle installeating ~60% of every job.What changed
Lint: 26 jobs → 1. A single root
.rubocop.ymlgoverns the monorepo, so onebundle exec rubocopalready lints all 840 files across the 13 packages — thepackagesmatrix ran the identical command 13× per Ruby version. The per-packagebundle installwas dead weight on top:bundle execat the root restricts activation to the root Gemfile, so rubocop never saw those gems anyway.TargetRubyVersion: 3.0pins the parser, so theruby-versiondimension couldn't change the result either.Verified against a pristine
BUNDLE_PATHholding the root Gemfile alone —rails,sqlite3,mongoid,activerecordall absent from the load path, rubocop still exits 0 over all 840 files.The unixODBC step goes with it (it only existed to build
ruby-odbcfor the snowflake bundle); it is retained in thetestjob, which still needs it.Lint and test now run in parallel. They are independent; chaining them made the critical path the sum rather than the max.
deploynow listslintexplicitly, since it previously depended on lint only transitively throughtest— without that, a release could ship with RuboCop red.Gem caching on the shared
BUNDLE_PATH, keyed on Gemfiles/gemspecs rather than the gitignored lockfiles. The test key names only its own package'sGemfile-testso a one-package PR doesn't miss on all 24 cells, while keeping every gemspec, sincepath:deps mean a sibling's gemspec still affects resolution.The mirror-image dead weight in
test, too.Install dependencies on main repowas just as dead there: the Test step runs entirely underBUNDLE_GEMFILE=Gemfile-test, all 12 matrix packages declare their ownrspec/simplecov/simplecov_json_formatterin that file, and no other step touches root gems (Wait for MongoDBusesnc,Upload coveragereads the artifact). It cost 3.7s per cell — ~1 min 30 of runner time over the 24 cells — and pushed ~12MB of never-loaded gems into each of the 24 caches. Verified on a pristineBUNDLE_PATHthe root Gemfile never touched:forest_admin_datasource_toolkitinstalls 49 gems fromGemfile-testalone and runs 478 examples, 0 failures, withcoverage.jsonwritten. Dropping it makes the rootGemfileand*.gemspeccomponents of the test cache key redundant, so they go too.Concurrency group, same shape and placement as the one already in
security-fixes.yml: a PR's in-flight run is superseded by its next push.cancel-in-progressis conditional rather thantruebecausedeploypublishes gems frommain/beta— those runs must never be cancelled mid-flight.notify-ci-failure.ymlonly fires onbranches: [main], so a cancelled PR run cannot trip it.CLAUDE.md— the scaffolding checklist pointed at the lint matrix this PR deletes.Review notes
Verified and cleared:
hashFilesdegenerating to an empty key (all patterns match real files); a restored cache serving stale gems (impossible — no committed lockfiles, so bundler re-resolves every run and/tmp/bundleis only an artifact store);deploypublishing without lint/test green (theif:carries no status function, so implicitneedsgating holds); the qlty/ruby-versioncoupling (untouched);notify-ci-failure.yml(keys off the workflow name). Branch protection onmainhas no required status checks, so collapsing the 26Lint (x, y)contexts into one cannot wedge PRs.One accepted regression, narrower than first stated: no job resolves
packages/*/Gemfileany more — the test job installsGemfile-test, a different file in 11 of 13 packages. An unresolvable package Gemfile now goes green. Accepted because the signal covered 3 of 13 packages by accident of an inconsistency, tested a dependency edge absent from the published gemspecs (none of those three declares aforest_adminruntime dep), and never exercised PR code. A deliberate release-time install smoke test is the right home for that question.Their syntax is still linted, though: RuboCop's default
Includecovers**/Gemfile, sobundle exec rubocop --list-target-filesat the root already lists all 13packages/*/Gemfileand all 13packages/*/*.gemspec— 26 files. Only dependency resolvability is lost, which makes the trade narrower than the paragraph above first claimed. (Gemfile-testis not among those 26 — it falls outside that defaultInclude.)Follow-ups (not blocking, both pre-existing)
zendesk,mambu_paymentsandgraphql_hasuradeclare siblings withoutpath:in theirGemfile, unlike the other 10 — sobundle installin those packages resolves published siblings rather than the working tree.forest_admin_test_toolkitis published to RubyGems but has no specs, noGemfile-test, and now no CI entry at all.🤖 Generated with Claude Code
Note
Parallelize lint and test jobs and add gem caching in
build.ymlneeds: [lint]dependency from the test job so lint and test run in parallel, halving CI wall clock time.bundle install+rubocop.cancel-in-progressfor PRs to auto-cancel superseding runs.coverageandlintto pass (needs: [coverage, lint]) instead of onlycoverage; packages without spec suites must be excluded from both the test matrix and coverage list.Macroscope summarized b5d29b4.