Skip to content

ci(build): halve CI wall clock by parallelizing jobs and caching gems - #380

Merged
matthv merged 2 commits into
mainfrom
perf/ci-parallel-jobs-and-gem-cache
Aug 31, 2026
Merged

ci(build): halve CI wall clock by parallelizing jobs and caching gems#380
matthv merged 2 commits into
mainfrom
perf/ci-parallel-jobs-and-gem-cache

Conversation

@matthv

@matthv matthv commented Aug 27, 2026

Copy link
Copy Markdown
Member

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 33058967442 showed no slow job — the cost was job count plus uncached bundle install eating ~60% of every job.

Before After
Jobs 50 27
Runner minutes ~52 min ~11 min 30
Wall clock ~3 min 10 ~1 min

What changed

Lint: 26 jobs → 1. A single root .rubocop.yml governs the monorepo, so one bundle exec rubocop already lints all 840 files across the 13 packages — the packages matrix ran the identical command 13× per Ruby version. The per-package bundle install was dead weight on top: bundle exec at the root restricts activation to the root Gemfile, so rubocop never saw those gems anyway. TargetRubyVersion: 3.0 pins the parser, so the ruby-version dimension couldn't change the result either.

Verified against a pristine BUNDLE_PATH holding the root Gemfile alone — rails, sqlite3, mongoid, activerecord all 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-odbc for the snowflake bundle); it is retained in the test job, 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. deploy now lists lint explicitly, since it previously depended on lint only transitively through test — 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's Gemfile-test so a one-package PR doesn't miss on all 24 cells, while keeping every gemspec, since path: deps mean a sibling's gemspec still affects resolution.

The mirror-image dead weight in test, too. Install dependencies on main repo was just as dead there: the Test step runs entirely under BUNDLE_GEMFILE=Gemfile-test, all 12 matrix packages declare their own rspec / simplecov / simplecov_json_formatter in that file, and no other step touches root gems (Wait for MongoDB uses nc, Upload coverage reads 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 pristine BUNDLE_PATH the root Gemfile never touched: forest_admin_datasource_toolkit installs 49 gems from Gemfile-test alone and runs 478 examples, 0 failures, with coverage.json written. Dropping it makes the root Gemfile and *.gemspec components 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-progress is conditional rather than true because deploy publishes gems from main/beta — those runs must never be cancelled mid-flight. notify-ci-failure.yml only fires on branches: [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: hashFiles degenerating 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/bundle is only an artifact store); deploy publishing without lint/test green (the if: carries no status function, so implicit needs gating holds); the qlty/ruby-version coupling (untouched); notify-ci-failure.yml (keys off the workflow name). Branch protection on main has no required status checks, so collapsing the 26 Lint (x, y) contexts into one cannot wedge PRs.

One accepted regression, narrower than first stated: no job resolves packages/*/Gemfile any more — the test job installs Gemfile-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 a forest_admin runtime 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 Include covers **/Gemfile, so bundle exec rubocop --list-target-files at the root already lists all 13 packages/*/Gemfile and all 13 packages/*/*.gemspec — 26 files. Only dependency resolvability is lost, which makes the trade narrower than the paragraph above first claimed. (Gemfile-test is not among those 26 — it falls outside that default Include.)

Follow-ups (not blocking, both pre-existing)

  • zendesk, mambu_payments and graphql_hasura declare siblings without path: in their Gemfile, unlike the other 10 — so bundle install in those packages resolves published siblings rather than the working tree.
  • forest_admin_test_toolkit is published to RubyGems but has no specs, no Gemfile-test, and now no CI entry at all.

🤖 Generated with Claude Code

Note

Parallelize lint and test jobs and add gem caching in build.yml

  • Removes the needs: [lint] dependency from the test job so lint and test run in parallel, halving CI wall clock time.
  • Adds gem cache steps to both lint and test jobs, keyed by OS, Ruby version, Gemfile, and gemspec files.
  • Simplifies the lint job: drops the Ruby version matrix, runs once on Ruby 4.0 at the repo root with a single bundle install + rubocop.
  • Adds workflow-level concurrency with ref-based grouping and cancel-in-progress for PRs to auto-cancel superseding runs.
  • Updates CLAUDE.md to reflect that new packages no longer need lint matrix entries, and clarifies test matrix and coverage artifact path conventions.
  • Behavioral Change: deploy now requires both coverage and lint to pass (needs: [coverage, lint]) instead of only coverage; packages without spec suites must be excluded from both the test matrix and coverage list.

Macroscope summarized b5d29b4.

@qltysh

qltysh Bot commented Aug 27, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@matthv
matthv force-pushed the perf/ci-parallel-jobs-and-gem-cache branch from 2723c42 to 1c78eb1 Compare August 27, 2026 12:49

@christophebrun-forest christophebrun-forest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-files at the root already includes every packages/*/Gemfile and packages/*/*.gemspec — RuboCop's default Include covers **/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.contexts on main is indeed [], so collapsing the 26 Lint (x, y) contexts cannot wedge PRs.

@christophebrun-forest christophebrun-forest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@matthv

matthv commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Both taken, thanks — the two changes are in the working tree and land with the next push.

The test job's Install dependencies on main repo is gone, and with it the root Gemfile / *.gemspec components of the test cache key, redundant once nothing installs them. Checked your reasoning rather than just taking it: all 12 matrix packages declare rspec + simplecov + simplecov_json_formatter in their own Gemfile-test, and the remaining steps touch no root gem. Then ran the job's real Test step against a BUNDLE_PATH the root Gemfile had never touched — forest_admin_datasource_toolkit resolved 49 gems from Gemfile-test alone, 478 examples, 0 failures, coverage.json written. Your 3.7s/cell figure puts the table's "After" at ~11 min 30 rather than ~13, so I've corrected it.

Concurrency group added, same shape and placement as the one already in security-fixes.yml:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Conditional rather than plain true on purpose: deploy publishes gems from main/beta, and a run cancelled between two gem push calls would leave a release half-shipped. github.ref is refs/pull/N/merge on a PR, so no cross-PR collision, and notify-ci-failure.yml only fires on branches: [main] — a cancelled PR run can't trip it.

On your Gemfile note — confirmed and folded into the description, which was too pessimistic. bundle exec rubocop --list-target-files at the root lists all 13 packages/*/Gemfile and all 13 gemspecs (26 files), so only dependency resolvability is lost, not syntax. Worth adding that Gemfile-test is not in those 26 — RuboCop's default Include covers **/Gemfile but not that name — so the 12 files the test job actually installs are the ones nothing lints. Small and pre-existing, but it belongs with the release-time install smoke test in the follow-ups.

matthv and others added 2 commits August 31, 2026 11:54
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>
@matthv
matthv force-pushed the perf/ci-parallel-jobs-and-gem-cache branch from ba983ba to b5d29b4 Compare August 31, 2026 09:54
@matthv matthv changed the title perf(ci): halve CI wall clock by parallelizing jobs and caching gems ci(build): halve CI wall clock by parallelizing jobs and caching gems Aug 31, 2026

@christophebrun-forest christophebrun-forest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@matthv
matthv merged commit b7879a0 into main Aug 31, 2026
31 checks passed
@matthv
matthv deleted the perf/ci-parallel-jobs-and-gem-cache branch August 31, 2026 12:54
christophebrun-forest added a commit that referenced this pull request Sep 1, 2026
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>
christophebrun-forest added a commit that referenced this pull request Sep 1, 2026
…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>
forest-bot added a commit that referenced this pull request Sep 1, 2026
# [1.41.0](v1.40.1...v1.41.0) (2026-09-01)

### Features

* **datasource-pylon:** Pylon datasource for the Ruby agent (EXT-4) ([#369](#369)) ([cf20168](cf20168)), closes [#380](#380)
@forest-bot

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.41.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants