Skip to content

A build-input pull request builds the production image once, not twice - #1420

Merged
dschmura merged 1 commit into
mainfrom
image-built-once-per-pr
Oct 17, 2026
Merged

dschmura merged 1 commit into
mainfrom
image-built-once-per-pr

Conversation

@dschmura

Copy link
Copy Markdown
Owner

A pull request that changed a build input — Gemfile.lock among them, so
every weekly Dependabot group — built the production image twice: in
docker_build, with the GitHub Actions layer cache, and in image_scan,
from scratch, before scanning it. Both build the same image: the same
context, the same Dockerfile, no target, linux/amd64. image_scan's cold
build already proves the image builds, so docker_build's second build
proved nothing new and billed for it.

On a private fork the second build was also the expensive one. On the
fork's last full run, docker_build took 794 seconds, 742 of them in its
build step; image_scan's build of the same image on the same pull
request, plus its Trivy scan, took 370. The cache cost more than it
saved there — most likely the mode=max export on a small runner, though
that was not measured on its own.

docker_build now runs on every push to main, unfiltered, which remains
the merged tree's proof, and on a pull request only when its own
workflow changes, so a change to it is still tested before it lands.
image_scan's pull_request paths widen to the full build-input list
(Gemfile, .tool-versions and bin/docker-entrypoint join Dockerfile,
.dockerignore and Gemfile.lock), so it is each build-input pull
request's single image build. The pre-push hook still builds locally on
the same inputs.

The template invariants change first: docker_build's pull request paths
are now exactly its own workflow, image_scan's name every build input,
and a new example pins that the two workflows build the same image —
the fact the change rests on. The two trigger examples were red against
the old workflows; the same-image example was already green.

Touching image_scan.yml was the trigger #1404 waited for: its 18-line
header and the 27-line cache-scope comment shrink to two-line pointers.
The rationale already lived in /docs/developer/testing under #536; the
one point it lacked — why keying the cache on the base image's digest
would not help — moves there.

Closes #1404

Verification

  • Template invariants: the two trigger examples were red against the old workflows, then green; the new same-image example (context, Dockerfile, target, platforms) green throughout. 115 examples, 0 failures.
  • This pull request triggers both workflows on purpose: docker_build because its own file changed, image_scan because its file changed — so CI exercises the new trigger shapes, and workflow_audit (zizmor) audits the edited workflows.
  • Docs specs 32, markdown lint, comment-block gate clean; full suite 6058 examples, 0 failures, parity 6058/6058.

A pull request that changed a build input — Gemfile.lock among them, so
every weekly Dependabot group — built the production image twice: in
docker_build, with the GitHub Actions layer cache, and in image_scan,
from scratch, before scanning it. Both build the same image: the same
context, the same Dockerfile, no target, linux/amd64. image_scan's cold
build already proves the image builds, so docker_build's second build
proved nothing new and billed for it.

On a private fork the second build was also the expensive one. On the
fork's last full run, docker_build took 794 seconds, 742 of them in its
build step; image_scan's build of the same image on the same pull
request, plus its Trivy scan, took 370. The cache cost more than it
saved there — most likely the mode=max export on a small runner, though
that was not measured on its own.

docker_build now runs on every push to main, unfiltered, which remains
the merged tree's proof, and on a pull request only when its own
workflow changes, so a change to it is still tested before it lands.
image_scan's pull_request paths widen to the full build-input list
(Gemfile, .tool-versions and bin/docker-entrypoint join Dockerfile,
.dockerignore and Gemfile.lock), so it is each build-input pull
request's single image build. The pre-push hook still builds locally on
the same inputs.

The template invariants change first: docker_build's pull request paths
are now exactly its own workflow, image_scan's name every build input,
and a new example pins that the two workflows build the same image —
the fact the change rests on. The two trigger examples were red against
the old workflows; the same-image example was already green.

Touching image_scan.yml was the trigger #1404 waited for: its 18-line
header and the 27-line cache-scope comment shrink to two-line pointers.
The rationale already lived in /docs/developer/testing under #536; the
one point it lacked — why keying the cache on the base image's digest
would not help — moves there.

Closes #1404
@dschmura
dschmura merged commit fa855fe into main Oct 17, 2026
19 checks passed
@dschmura
dschmura deleted the image-built-once-per-pr branch October 17, 2026 01:42
dschmura added a commit that referenced this pull request Oct 17, 2026
…1421)

A private repository is billed per job, each rounded up to the minute.
CI ran its static checks as four jobs — scan_ruby (Brakeman and
bundler-audit), scan_js (the importmap audit), lint (RuboCop and the
comment-block gate) and lint_docs (markdown and ERB) — that each
finished
well inside a minute: 25, 28, 36 and 58 seconds on a private fork's last
full run. Four billed minutes for about two and a half of work, and a
minute of that was the same setup repeated: Ruby set up four times
(59 seconds between them) and libvips installed twice (29).

They are now one `static` job: one checkout, one Ruby, one RuboCop
cache, one libvips, then every check as its own step. Each check step
carries `if: ${{ !cancelled() }}`, so it still runs after an earlier one
fails and a push reports every failing check, as four separate jobs did;
the setup steps carry no such guard, so a failed setup still stops the
job. A new template invariant pins both halves — the four jobs gone, and
every check guarded — and a mutation that dropped the guard from the ERB
step turned it red.

This changes required status-check names. This repository's main ruleset
requires scan_js, scan_ruby, lint and lint_docs by name; this pull
request reports `static` instead, so it waits on those four until the
ruleset requires `static` in their place. The forks were checked:
MClassrooms requires nothing, modelrails_base_wads requires only
`CI/test` (kept), and the private fork's plan has no rulesets. No fork
workflow `needs:` the old jobs.

The names were also written in prose: js_advisory.yml's header (which
said scan_js "remains a required ruleset context"), the Gemfile's list
of
jobs that boot Rails, three lefthook.yml comments, getting-started's
Gate 2 table and its js_advisory row (which named scan_js as the
required check), and the scan_js workflow spec's positive control, which
now
expects `ci.yml / static`. The comments touched shrink to two-line
gists.
The playbook's deployment and markdown standards name the old jobs too;
they follow in a playbook pull request.

## Before merging

The main ruleset must require `static` in place of scan_js, scan_ruby,
lint and lint_docs. Until then this pull request waits on four checks no
workflow reports any more.

## Verification

- Full suite (`bin/parallel-rspec`): 6057 examples, 0 failures,
example-count parity, coverage floor met, exit 0; pre-push hooks green.
- Template invariants: the new example fails when the `!cancelled()`
  guard is removed from the ERB step (mutation checked).
- `lefthook validate`: All good.
- Merge with #1420 conflicts in one place: two adjacent rows of
  getting-started's scheduled-workflows table. Whichever merges second
  takes a one-line rebase.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deferred: image_scan.yml's cache-scope comment shrinks to a gist — trigger: the next change to image_scan.yml

1 participant