ci: skip the heavy jobs when nothing but documentation changed

Every push and PR paid the full four-minute job — fmt, clippy, the offline
suite, a release-profile build and the dependency audit — even when the diff
was a README or AGENTS edit, and every master push built and published an
image for a commit that cannot have changed it.

`ci.yml` gains a `changes` gate job: a push or PR whose *entire* diff is
markdown skips `test`, which then reports as *skipped* instead of missing —
the reason this is a gate job and not a workflow-level `paths` filter, which
leaves a required status check waiting for a check run that will never appear.
Anything non-markdown (and an empty diff, e.g. a re-run of the same commit)
runs the full job, so a new directory of code cannot slip through a stale
allowlist. `schedule`/`workflow_dispatch`, which have no `before` commit, also
run it.

`docker.yml`'s `should-build` gate now also skips a branch push that touched
none of the image's inputs (`Dockerfile`, `docker-entrypoint.sh`,
`.dockerignore`, the manifests, `Cargo.lock`, this workflow, anything under
`crates/`); tag pushes always build.

Checked against this repo's real ranges: the docs-only commit 667f523
(AGENTS.md) → `code=false` (test skipped) and skip, a workflow commit →
`code=true` and build, the h2 bump (Cargo.lock) → build.
This commit is contained in:
2026-09-21 15:54:39 +08:00
parent 74ffe66884
commit 9eb865bb02
2 changed files with 67 additions and 6 deletions
+48
View File
@@ -4,6 +4,8 @@ name: CI
# job that exercises the real source sites and the token-gated pixiv tests. # job that exercises the real source sites and the token-gated pixiv tests.
# #
# Layering: # Layering:
# changes — decides whether anything but documentation changed; a docs-only
# push/PR skips `test` (which then reports as skipped, not missing).
# test — fmt + clippy + the full offline unit suite + a release-profile # test — fmt + clippy + the full offline unit suite + a release-profile
# build + cargo-audit dependency gate. Runs on every push and PR, # build + cargo-audit dependency gate. Runs on every push and PR,
# including forks (it needs no secrets). # including forks (it needs no secrets).
@@ -46,7 +48,53 @@ env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
jobs: jobs:
# Docs-only changes skip the heavy job: a README edit does not need a four
# minute Rust build (and it cannot break one). A gate job rather than a
# workflow-level `paths` filter — that leaves the run without a `test` check
# at all, and a required status check then waits for something that will
# never be reported, while a *skipped* job reports as neutral.
changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
code: ${{ steps.diff.outputs.code }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # the diff below needs the pushed range
- id: diff
shell: bash
run: |
set -euo pipefail
zero=0000000000000000000000000000000000000000
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="origin/${{ github.base_ref }}"
git fetch --quiet --no-tags origin "${{ github.base_ref }}"
changed="$(git diff --name-only "$base...HEAD")"
else
before="${{ github.event.before }}"
if [ -z "$before" ] || [ "$before" = "$zero" ]; then
# New branch or force push: no usable base to compare against,
# so the full suite runs. Same for schedule/dispatch, which have
# no `before` at all.
changed=""
else
changed="$(git diff --name-only "$before..${{ github.sha }}")"
fi
fi
# Only a change that is *entirely* markdown may skip the job;
# anything else — and an empty diff, i.e. a re-run of the same
# commit — counts as code.
code=true
if [ -n "$changed" ] && ! grep -qvE '\.md$' <<<"$changed"; then
code=false
fi
echo "changed: ${changed:-<no diff>}"
echo "code=$code" >> "$GITHUB_OUTPUT"
test: test:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Generous on purpose: the release-profile build below is cold on the very # Generous on purpose: the release-profile build below is cold on the very
# first run (thin LTO + codegen-units = 1 across every dependency), and a # first run (thin LTO + codegen-units = 1 across every dependency), and a
+19 -6
View File
@@ -75,6 +75,7 @@ jobs:
- id: check - id: check
shell: bash shell: bash
run: | run: |
zero=0000000000000000000000000000000000000000
if [ "$GITHUB_REF_TYPE" = "branch" ]; then if [ "$GITHUB_REF_TYPE" = "branch" ]; then
# A branch run can start before the release tag for its commit # A branch run can start before the release tag for its commit
# reaches the remote — pushing master first is the usual way to hit # reaches the remote — pushing master first is the usual way to hit
@@ -87,13 +88,25 @@ jobs:
# pushing the branch and the tag together # pushing the branch and the tag together
# (`git push origin master vX.Y.Z`) removes it. # (`git push origin master vX.Y.Z`) removes it.
git fetch --tags --force --quiet origin git fetch --tags --force --quiet origin
if git tag --points-at "$GITHUB_SHA" | grep -q .; then
echo "commit already tagged; the tag run builds the image"
echo "build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Nothing the image is made of changed — a documentation or
# workflow-only commit — so there is no new image to publish. The
# PR trigger's path list plus the crate sources, which the image
# compiles into the binary.
before="${{ github.event.before }}"
if [ -n "$before" ] && [ "$before" != "$zero" ] \
&& ! git diff --name-only "$before..$GITHUB_SHA" \
| grep -qE '^(Dockerfile|docker-entrypoint\.sh|\.dockerignore|Cargo\.toml|Cargo\.lock|\.github/workflows/docker\.yml|crates/)'; then
echo "no build input changed; skipping the image build"
echo "build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi fi
if [ "$GITHUB_REF_TYPE" = "branch" ] && git tag --points-at "$GITHUB_SHA" | grep -q .; then echo "build=true" >> "$GITHUB_OUTPUT"
echo "commit already tagged; the tag run builds the image"
echo "build=false" >> "$GITHUB_OUTPUT"
else
echo "build=true" >> "$GITHUB_OUTPUT"
fi
# No `actions/checkout` here on purpose: `docker/build-push-action` defaults # No `actions/checkout` here on purpose: `docker/build-push-action` defaults
# to the Git context (`https://github.com/<owner>/<repo>.git#<ref>`), so # to the Git context (`https://github.com/<owner>/<repo>.git#<ref>`), so