ci: skip builds when a change touches no build inputs - #3763
Conversation
amandeavor
left a comment
There was a problem hiding this comment.
Reviewed the path classifier, embedded-file guard, CI dependency graph, prerelease range selection, and stable-release asset verification. The classifier passes all 40 self-test cases, the PR checks pass across Linux, Windows, and macOS, and the gate consistently fails open when it cannot prove a change is inert. I also checked the release demotion and downstream notification/Homebrew dependencies. I did not find a correctness blocker.
|
Solid PR overall — the design write-up and the One correctness bug worth fixing before merge, on the notify-discord:
needs: [verify-assets]
if: ${{ !inputs.prerelease }}
So as written, both jobs run as soon as Fix: if: ${{ !inputs.prerelease && needs.verify-assets.result == 'success' }}on both (Rest of the review — duplicated merge-ref guard, hardcoded asset list in |
e18a05e to
c26851c
Compare
There was no path filtering anywhere in .github/. Every push to develop ran pre-release plus 8 build jobs (5 platforms + DEB + RPM + Create Release) and published a dev-*-rc.N prerelease, and every PR ran 10 job instances. Replaying the last 100 merges into develop, 8 were pure markdown and paid for all of it. .github/inert-paths.conf holds git pathspecs for the paths that cannot change the binary or CI. changed-scope.sh subtracts them from a diff range and answers through its exit status. Git does the matching, so there is no glob engine of ours to get wrong. Every failure direction leads to building rather than to shipping a stale artifact: - The list is inverted, so an unrecognised path builds. - The skip verdict is status 3, not 1, because bash hands out the low statuses itself -- 1 for a set -u violation or a failed command, 2 for a syntax error, 127 for a missing one. An accident cannot be mistaken for a skip. - Gated jobs skip only on an explicit build=false, and the gate's own guards run before it writes that, so a red gate runs CI rather than waving the PR through green. - --no-renames throughout: rename detection reports only a rename's destination, so moving an embedded file into docs/ would otherwise look inert on a commit that no longer compiles. hooks/** is deliberately not excluded: src/hooks/init.rs include_str!s ten files out of hooks/, six of them .md. A bare :(exclude)*.md would swallow them, because without `,glob` a pathspec `*` also matches `/`. check-embed-scope.sh fails the build if that ever happens, and runs ungated so it still fires on the docs-only PRs that skip everything else. .claude/hooks/ is excluded from the build gate but routed to the security job through .github/security-paths.conf: those shell scripts run on contributors' machines, and semgrep is Rust-only. That job's pattern scan was Rust-only too, so it gains a shell scan. check-embed-scope.sh splits each macro onto its own line before matching. grep -o does not match overlapping windows, so the 80 characters trailing a literal embed swallowed whichever embed followed it, and that one was dropped as literal and never reported -- the exact concat!(env!(..)) form the guard exists to catch was invisible whenever a plain include_str! preceded it. pr-target-check.yml stops flagging release-please. Its only exemption was the develop->master promotion, so release-please's own version-bump PR was labelled wrong-base and told to target develop on every release since 0.37.1. Both maintainer routes are now keyed by branch, because the account behind them is not stable: the promotion has been opened by a human and by two different apps. Replayed over the last 100 PRs into master, that flips 17 verdicts, all of them release-please's, and exempts nothing else. Both exemptions are conditioned on the branch living in this repo, because a fork's head branch name is the contributor's to choose; every exempt route already satisfies that, so the clause changes no verdict. pr-target-check.yml also gains the matching base-branch filter. Its job only acts on a PR into master, so every PR into develop was starting a run that skipped; the last 20 runs did nothing. It also holds a pull-requests:write app token on pull_request_target, so a run it cannot act on is exposure with no upside. That job's three existing scans diffed origin/master...HEAD, which on a PR into develop spans every merge since the last release rather than the PR. They now share one resolved base with the new shell scan, so they report the PR's own changes instead of naming files the author never touched. Resolving the base once also means each scan separates its git call from the grep that needs `|| true` for a no-match, so a git failure fails the step rather than passing as a clean verdict. cd.yml diffs from the last RC rather than from github.event.before: develop runs are cancel-in-progress, and a docs push landing on a cancelled source build would otherwise skip and strand that source change with no RC. The master path moves to its own workflow, stable-release.yml, and is not gated. install.sh follows the redirect on /releases/latest and downloads that tag's tarballs, so a stable release without assets breaks every fresh install; a bug in the develop gate must not be able to cause that. release.yml gains a verify-assets job that asserts all 8 assets uploaded non-empty and that checksums.txt names nothing that failed to upload, demoting the release out of /releases/latest if not, with Discord and the Homebrew tap waiting on it. Fixes rtk-ai#189 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c26851c to
ecada25
Compare
|
Thanks — two of the cleanup notes turned out to be worth more than the blocker, and both are now fixed in the PR. On the blocker itself I think the rule is the other way round, and I'd rather not add the condition. The implicit
|
What
Adds path-based change filtering to
.github/, which had none. Every push todevelopranpre-releaseplus 8 build jobs (5 platforms + DEB + RPM + CreateRelease) and published a
dev-*-rc.Nprerelease; every PR ran 10 job instances.Replaying the last 100 merges into
develop, 8 were pure markdown and paid forall of it.
.github/inert-paths.confholds git pathspecs for paths that cannot changethe binary or CI.
changed-scope.shsubtracts them from a diff range andanswers through its exit status. Git does the matching, so there is no glob
engine of ours to get wrong.
About #189 — read before merging
This does not implement what #189 proposed, and that is deliberate. The
issue asked to gate the binary build on the release path. Two problems, both
verified:
git diff PREV_TAG..HEAD -- '*.rs' 'Cargo.toml' 'Cargo.lock' 'build.rs', butrelease-please's own release commit always bumps the version in
Cargo.tomland
Cargo.lock.code_changedwould betrueon every release.masterbreaks installs.install.shresolves theversion by following the redirect on
/releases/latest, then downloads thattag's tarballs by name. A stable release published without assets becomes
"Latest" and every fresh
curl | shfails.The savings #189 was after are taken on the develop and PR side instead, where
they are roughly 8x larger (369 RCs vs ~46 stable tags) and carry no such risk.
The master path is moved to its own workflow and left ungated.
Closing #189 on merge for that reason — if you would rather keep it open to
track the release-path idea, drop the
Fixesline before merging.Design
Every failure direction leads to building rather than to shipping a stale
artifact:
statuses itself (1 for
set -u, 2 for syntax, 127 for missing). An accidentcannot be mistaken for a skip.
build=false, and the gate's own guardsrun before it writes that, so a red gate runs CI rather than waving a PR
through green.
--no-renamesthroughout: rename detection reports only a rename'sdestination, so moving an embedded file into
docs/would otherwise lookinert on a commit that no longer compiles.
The
,globtraphooks/**is deliberately not excluded.src/hooks/init.rsinclude_str!sten files out of
hooks/, six of them.md. A bare:(exclude)*.mdwouldswallow them, because without
,globa pathspec*also matches/— and adocs-only commit would then ship a stale binary.
check-embed-scope.shenforces this mechanically: it resolves everyinclude_str!/include_bytes!target under all tracked*.rs(collapsingnewlines first, since rustfmt wraps long paths, then splitting each macro onto
its own line so
grep -o's non-overlapping windows cannot let one embed hidethe next) and fails if any is excluded, missing, unresolvable, or written in a
form it cannot verify. It runs ungated, so it still fires on the docs-only PRs
that skip everything else.
Other changes
stable-release.yml— the master path moves out ofcd.ymlso a bug inthe develop gate cannot reach a release. It is not path-gated.
verify-assetsinrelease.yml— asserts all 8 assets uploaded non-emptyand that
checksums.txtnames nothing that failed to upload. On failure itdemotes the release to prerelease, taking it out of
/releases/latestsoinstalls fall back to the last good tag. Discord and the Homebrew tap wait on
it. It detects and demotes; it cannot prevent — see Stable releases are 'Latest' for ~6-7 minutes before their assets exist, breaking install.sh #3759.
.claude/hooks/is excluded from the build gate but routed to thesecurity job via
.github/security-paths.conf. Those shell scripts run oncontributors' machines and semgrep is Rust-only; that job's pattern scan was
Rust-only too, so it gains a
Shell script scanstep.cd.ymldiffs from the last RC rather thangithub.event.before: developruns are
cancel-in-progress, and a docs push landing on a cancelled sourcebuild would otherwise skip and strand that source change with no RC.
origin/master...HEAD, which on a PR intodevelopspans every merge sincethe last release rather than the PR. On this PR that was 88 files against 10,
and it reported
Cargo.toml,tracking.rsandinit.rsas "critical filesmodified" by a PR touching none of them. All four scans now share one
BASE_SHAresolved once, so they report the PR's own changes. Resolving itonce also lets each scan separate its
gitcall from thegrepthat needs|| truefor a no-match — a failinggitnow fails the step instead ofpassing as a clean verdict.
pr-target-check.ymlgainsbranches: [master], matching what its jobalready tested; every PR into
developwas starting a run that only skipped(all 20 most recent runs did nothing), and it holds a
pull-requests:writeapp token on
pull_request_target. It also stops flagging release-please:its only exemption was the
develop→masterpromotion, so release-please'sown version-bump PR has been labelled
wrong-baseand told to targetdevelopon every release since 0.37.1. Both exemptions are keyed by branch(the account behind them is not stable — the promotion has been opened by a
human and by two different apps) and require the branch to live in this repo,
since a fork's head branch name is the contributor's to choose.
Verification
changed-scope.sh --self-test— 40 assertions, wired into CI.check-embed-scope.sh— 49 embed sites, verified non-vacuous by injecting adocs/embed (flagged DRIFT) and by removing,glob(flagged the sixembedded
hooks/*.md).develop: 92 build /8 skip, and every file in all 8 skipped merges is markdown — no
.rs, nohooks/, noCargo.*.verify-assetslogic run against the realv0.46.0release: clean on thegood release, catches a dropped tarball in both checks, and aborts without
demoting when
ghis unavailable.pr-target-check.ymlreplayed over the last 100 PRs intomaster: 17verdicts flip, all of them release-please's, 0 unintended exemptions, 65
contributor PRs still flagged. The same-repo clause changes no verdict.
refs/pull/3763/merge: the summarynow lists exactly the files this PR touches, and with
BASE_SHAunset everyscan fails hard rather than reporting clean.
Known limitations
Security Scanjob still runs the full Rust toolchain for a shell-onlychange (~5 min for a shell grep). Fix is step-level conditions; deferred
because it needs testing on two real PR shapes. Tracked in ci.yml security jobs: Rust steps on shell-only PRs, unguarded semgrep baseline #3761, closed
until this merges.
is pre-existing and unrelated to this gate — Stable releases are 'Latest' for ~6-7 minutes before their assets exist, breaking install.sh #3759.
Follow-ups opened while working on this
rtk-rewrite.shhooks both claimrtk-hook-version: 3🤖 Generated with Claude Code