Skip to content

ci(release): refuse to publish a build from a modified checkout - #147

Merged
juacker merged 1 commit into
mainfrom
fix/version-stamp-dirty-on-tagged-builds
Aug 3, 2026
Merged

ci(release): refuse to publish a build from a modified checkout#147
juacker merged 1 commit into
mainfrom
fix/version-stamp-dirty-on-tagged-builds

Conversation

@juacker

@juacker juacker commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The v26.8.1 Windows installer reports v26.8.1-dirty in Settings → About. This does not sanitise that string — it stops us shipping the artifact that earns it.

What the string means

src-tauri/build.rs bakes git describe --tags --always --dirty into CLAI_GIT_DESCRIBE; app_version_detail prefers it over CARGO_PKG_VERSION whenever it looks version-like. So the release job's checkout state is what users read in About.

The flag is truthful. git describe --dirty calls refresh_index() before run_diff_index() (git builtin/describe.c:802-815 @ v2.55.0, present since v2.20), so stat-only skew cannot produce it — bumping a file's mtime leaves describe clean (reproduced). Also ruled out on this repo: no CRLF-committed or mixed-eol tracked files (git ls-files --eol: 338 i/lf, 37 binary, 1 none), no tracked symlinks, no paths that fail to check out on Windows, and a clone with core.autocrlf=true stays clean. Something in the windows-latest job really modified tracked content. What, is still unknown.

My first attempt at this suppressed -dirty for tag-exact builds. Review killed it: it would have made releases stop reporting a real defect. Reverted — src-tauri/src/commands/app_info.rs is untouched.

What this does

  1. scripts/assert-pristine-tree.sh — fails when tracked files differ from HEAD, printing git status + git diff --stat HEAD. It runs git update-index -q --refresh first, because git diff-index is plumbing that compares cached stat data with no content fallback: without the refresh, a fresh clone's mtimes alone fail a pristine tree (reproduced both ways).
  2. Two call sites in release.yml — before the build (fail cheaply) and after it (the run that can honestly claim the uploaded assets match the tag; cargo rewriting the tracked src-tauri/Cargo.lock would land in that window). A post-build failure fails the build job that publish needs, so the release stays a draft and sync-to-r2 never runs.
  3. .gitattributes: *.sh text eol=lf — the gate runs through bash on windows-latest, where Git for Windows would otherwise check the script out with CRLF and kill it on set -euo pipefail. Verified: in a core.autocrlf=true clone the .sh files stay LF while Makefile/*.ts do convert, and no file renormalises.
  4. build.rs reports what it baked as a cargo:warning when CI is set — the value, or the reason nothing was baked.

Heads-up before merging

This gate would have failed v26.8.1's Windows job, so the next tag may fail until the cause is found. That is the intent — an artifact that cannot be mapped back to a commit should not reach users — but it is a policy call, hence draft. With git status in the log the investigation should be short.

The second half of the puzzle

The published Linux .deb and macOS .app for v26.8.1 contain the literal 26.8.1 exactly once and no v26.8.1 anywhere, i.e. no describe string was baked on those platforms at all, while Windows baked a dirty one. One tag, three platforms, three outcomes, and nothing in the build log said which. Not caused here, not fixed here; item 4 makes the next release say so out loud. (Cache staleness is excluded: swatinem/rust-cache@v2 does not cache workspace crates, and cargo replays build-script warnings.)

Verification

  • cargo fmt; cargo clippy --lib; cargo test --lib app_info (unchanged code, still green); CI=1 cargo build --lib emits exactly one warning, without CI none.
  • Script exercised on scratch repos: mtime-only skew → pass; real edit → fail; staged-only change → fail with a non-empty diff.
  • Post-build false-positive check on a real build: npm run build then cargo build --release --lib (which runs tauri_build::build()), gate clean after each.
  • Reviewed in four independent read-only rounds; reports in the workspace under reviews/version-stamp-*.md. Round 1 found the original approach wrong, rounds 3 and 4 returned production_quality.

CI has not run on this branch yet at the time of writing.

The v26.8.1 Windows installer reports `v26.8.1-dirty` in About. That string
is `git describe --tags --always --dirty`, baked in by `src-tauri/build.rs`
and displayed by `app_version_detail`, so it says the binary users installed
was built from a tree that did not match the tag.

The flag is trustworthy. `git describe --dirty` calls `refresh_index()`
before `run_diff_index()` (git's `builtin/describe.c`, unchanged since
v2.20), so stat-only skew cannot produce it: bumping a file's mtime leaves
describe clean, and this repo has no CRLF-committed or mixed-eol tracked
files, no tracked symlinks, and no paths that fail to check out on Windows.
Something in the `windows-latest` job modified tracked content. What, is
still unknown.

So don't paper over the marker — the first version of this change suppressed
`-dirty` for tag-exact builds, which would have made releases stop reporting
a real defect. Fail the release job instead when tracked files differ from
HEAD, and print them. An artifact that cannot be mapped back to a commit
should not reach users.

The check runs twice, because `build.rs` reads git state *during* the build:
once before, to fail cheaply, and once after, since that is the run that can
claim the uploaded assets match the tag (cargo rewriting the tracked
`src-tauri/Cargo.lock` would land in that window). A post-build failure
fails the `build` job that `publish` needs, so the release stays a draft.
`git diff-index` is plumbing that compares cached stat data without a
content fallback, so the script refreshes the index first — otherwise a
fresh clone's mtimes alone can fail a pristine tree.

This gate would have failed v26.8.1's Windows job, so the next tag may fail
until the cause is found. With `git status` in the log that should be short.

`.gitattributes` pins `*.sh` to LF: the gate runs through `bash` on
windows-latest, where Git for Windows would otherwise check the script out
with CRLF and break it on its first line. Every tracked `.sh` blob is
already LF, so nothing renormalises.

The same puzzle has a second half: the published Linux `.deb` and macOS
`.app` for v26.8.1 contain the literal `26.8.1` exactly once and no
`v26.8.1` anywhere, meaning no describe string was baked on those platforms
at all while Windows baked a dirty one. Nothing in the build log
distinguished those outcomes, so `build.rs` now reports what it baked — or
why it baked nothing — as a `cargo:warning` when `CI` is set.
@juacker

juacker commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

First data from the new instrumentation, this PR's own CI run (30829601896), both jobs green:

Check & Build (ubuntu)  warning: clai@26.8.1: CLAI_GIT_DESCRIBE=c740854
Windows check           warning: clai@26.8.1: CLAI_GIT_DESCRIBE=c740854

Two things follow.

  1. The log line works, on both platforms, and ci.yml's shallow --no-tags checkout explains the bare hash — which resolve_version_detail already filters back to the crate version. So "no describe baked" is not a platform quirk; a job either has the tag or it does not. That makes the v26.8.1 Linux/macOS result (no describe string in the binary at all, not even a hash) stranger, not less strange: those jobs did fetch the tag. Still open.

  2. windows-latest came out clean here — checkout + npm ci + cargo on Windows produced no -dirty. So whatever dirtied the v26.8.1 release tree is not generic Windows checkout behaviour; it is specific to the release job, i.e. the tag checkout or something inside tauri-action (vite, or the NSIS/WiX bundling that ci.yml never runs). That is exactly the window the second, post-build gate covers.

@juacker
juacker marked this pull request as ready for review August 3, 2026 16:26
@juacker
juacker merged commit d709d88 into main Aug 3, 2026
2 checks passed
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.

1 participant