Skip to content

CI: skip the image and web jobs on pull requests that change no code #782

Description

@mforce

What

On a pull request that changes no code, skip Image build + Trivy scan and Web typecheck, test, and build. Leave Build and test running. Split out of the "skip CI on documentation PRs" idea after reading what actually runs on a docs-only PR here.

Measured, 2026-09-12

7 of the last 40 merged PRs changed nothing under src/, web/, tests/, tools/, deploy/, .github/, Directory.* or the solution file (#701, #718, #731, #744, #754, #757, #768). About 17%.

Per-job wall clock from #775's measurements on a green main run: Build and test 568s, Web typecheck, test, and build 213s, Image build + Trivy scan 129s. So the two jobs proposed here are roughly 340s of the ~900s a docs-only PR spends today, and they are the portion that cannot be affected by prose.

Why Build and test must keep running on a docs-only PR

This is the part that makes the obvious version of the idea wrong. In this repository "documentation" is not outside the test suite's scope — it is something the suite polices. Four gates are load-bearing on a markdown-only change:

Skipping Build and test on docs PRs would stop all four running on exactly the changes they were written to catch.

The hazard that decides the implementation

publish declares needs: [build-and-test, web, image]. A job whose needs dependency was skipped is itself skipped. So any gating must be scoped to github.event_name == 'pull_request' and must not apply on push to main or on workflow_dispatch.

Get that wrong and a merge to main skips image, which skips publish, which means no ghcr.io/<owner>/<repo>:sha-<commit> for that commit. Per #351 a release drafted at a commit with no image can never be promoted, and the release stays a draft with no git tag. ci.yml's own header comment describes that exact repair scenario as the thing the workflow_dispatch path exists to recover from. This issue must not manufacture a second route into it.

image also feeds publish the image_id output that proves the artifact handoff carried the scanned bytes. That contract is unchanged on main and must stay unchanged.

Mechanism: an if: gate, not on.pull_request.paths

Two ways to skip a job, and only one is safe here.

on.pull_request.paths is the trap. It gates the whole workflow, so it cannot skip two jobs and keep a third. Worse, when a required status check lives in a workflow that never ran, GitHub leaves that check in Expected and the PR can never merge. The usual workaround is a second shim workflow declaring a job of the same name on the inverse filter, which is a maintenance burden and a silent-drift risk.

A job skipped by if: reports as skipped, which satisfies a required check. So add one cheap gate job that computes whether the PR touched code, and put if: on web and image referencing its output.

Compute the changed paths with plain git diff --name-only against the merge base. Do not add tj-actions/changed-files or dorny/paths-filter for this — AGENTS.md names the 2025-03 tj-actions/changed-files compromise as the reason third-party actions are SHA-pinned, and a three-line git diff needs no dependency at all.

Decide before writing code

1 — establish which checks are actually required. gh api repos/mforce/cluckwork/branches/main/protection returns 403 for a personal access token, so the required-check list could not be read while writing this. If Web typecheck, test, and build or Image build + Trivy scan is required, the if: approach above is not merely preferable, it is the only one that works. Confirm before implementing.

2 — define "touched code" once, and in one place. The gate's path expression is a second copy of a fact that e2e-smoke.yml already encodes in its own paths: filter (src/**, web/**, tools/simulation/**, deploy/**). Two lists that are supposed to mean the same thing will drift. Decide whether the gate reuses that set, deliberately differs from it, and say which.

3 — web and image do not have the same trigger set. Web typecheck, test, and build should plainly run whenever web/** changes. Image build + Trivy scan also hosts the --locked-mode drift guard, so it must run on any change to Directory.Packages.props, a .csproj, a packages.lock.json or the Dockerfile, not only on src/**. Deriving one shared condition for both jobs would be the easy mistake.

4 — decide what happens on a mixed PR. A PR touching both docs and code runs everything. That is obvious, and it is also the case the gate must not get subtly wrong by testing "are any docs changed" rather than "are no code files changed".

Non-goals

Do not touch Build and test, for the reasons above. Do not touch any security gate (#146). Do not touch e2e-smoke.yml, which already path-filters and already skips docs-only PRs, so that lever is spent. This does not reduce the number of tests anywhere; #775 covers the wall clock of the suite itself, and this is only about not running two jobs that cannot be affected by the change under review.

Relationship to #775

#775 is about where Build and test's 568s goes. This is a smaller, independent saving that does not require answering that question first, and it removes nothing from the critical path #775 is trying to shorten. It can land before or after #775 without interacting with it.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestgithub_actionsPull requests that update GitHub Actions codesize:SHours to a day; few files, no migration

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions