From 422e13547aa3c325566bf58e9e4cb44afbd84001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Ca=C3=B1ete?= <2930882+juacker@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:50:22 +0200 Subject: [PATCH] ci(release): refuse to publish a build from a modified checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitattributes | 8 +++++ .github/workflows/release.yml | 17 ++++++++++ scripts/assert-pristine-tree.sh | 38 +++++++++++++++++++++++ src-tauri/build.rs | 55 +++++++++++++++++++++++++++------ 4 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/assert-pristine-tree.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..090f0d15 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Shell scripts must keep LF endings in the working tree. The release job runs +# scripts/assert-pristine-tree.sh through `bash` on windows-latest, and Git for +# Windows checks text files out with CRLF by default. `bash ` ignores the +# shebang but reads `\r` as part of every command, so the script dies on its +# first real one (`set: pipefail\r: invalid option name`) and takes the release +# step with it. Every tracked `.sh` blob is already LF, so this constrains +# checkout only and renormalises nothing. +*.sh text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0160617..dbc530ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,6 +77,14 @@ jobs: - name: Install npm dependencies run: npm ci + # The version baked into the binary is `git describe --dirty`, so a + # modified checkout ships as `-dirty` — the v26.8.1 Windows + # installer did. Fail before spending a build on an artifact we could not + # map back to the tag. See scripts/assert-pristine-tree.sh. + - name: Verify pristine checkout + shell: bash + run: bash scripts/assert-pristine-tree.sh "after npm ci, before build" + - name: Build Tauri app uses: tauri-apps/tauri-action@v0 env: @@ -87,6 +95,15 @@ jobs: releaseId: ${{ needs.create-release.outputs.release_id }} args: --target ${{ matrix.target }} + # `build.rs` reads git state *during* the step above, so the check before + # it only proves the tree was clean beforehand. Repeat it afterwards: this + # is the run that can honestly say the uploaded assets match the tag. + # Failing here fails the `build` job, which `publish` needs, so the + # release stays a draft even though tauri-action already attached assets. + - name: Verify the build changed no tracked files + shell: bash + run: bash scripts/assert-pristine-tree.sh "after build" + publish: name: Publish Release needs: [create-release, build] diff --git a/scripts/assert-pristine-tree.sh b/scripts/assert-pristine-tree.sh new file mode 100644 index 00000000..45f57818 --- /dev/null +++ b/scripts/assert-pristine-tree.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# Fail when tracked files differ from HEAD. +# +# `src-tauri/build.rs` stamps `git describe --tags --always --dirty` into the +# binary and the About page displays it, so a build produced from a modified +# checkout advertises itself as `-dirty`. The v26.8.1 Windows +# installer shipped exactly that. An artifact that cannot be mapped back to a +# commit should not reach users, so the release job runs this check instead of +# publishing one. +# +# `git update-index --refresh` runs first because `git diff-index` is plumbing: +# it compares the index's cached stat data and reports a difference without +# falling back to a content comparison, so a checkout whose mtimes moved — any +# fresh CI clone — can look modified when it is not. `git describe --dirty` +# refreshes the index itself (git's `builtin/describe.c`); this check has to. +# +# Usage: bash scripts/assert-pristine-tree.sh "" +set -euo pipefail + +context="${1:-checkout}" + +# Best effort: an unwritable or locked index leaves stale stat data, which can +# only produce a false failure below — with the diff printed, so it stays +# diagnosable. +git update-index -q --refresh || true + +echo "describe: $(git describe --tags --always --dirty)" + +if git diff-index --quiet HEAD --; then + echo "Tracked files match HEAD ($context)." + exit 0 +fi + +echo "::error::Tracked files differ from HEAD ($context); this build would be stamped -dirty." +git status --porcelain --untracked-files=no +git diff --stat HEAD -- +exit 1 diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0157d165..c7615193 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -7,15 +7,21 @@ fn main() { // release tag. Best-effort: in tarball / vendored builds there's no `.git` // (or no `git`), so the var is simply absent and the command falls back to // the crate version. - if let Ok(output) = Command::new("git") - .args(["describe", "--tags", "--always", "--dirty"]) - .output() - { - if output.status.success() { - let describe = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !describe.is_empty() { - println!("cargo:rustc-env=CLAI_GIT_DESCRIBE={describe}"); - } + let described = describe_head(); + if let Ok(describe) = &described { + println!("cargo:rustc-env=CLAI_GIT_DESCRIBE={describe}"); + } + + // Report the outcome in CI logs. v26.8.1 shipped a Windows installer + // stamped `26.8.1-dirty` while its Linux and macOS binaries baked no + // describe string at all — one tag, three platforms, three results — and + // nothing in the build log said which of those a job had produced, so the + // difference only surfaced by unpacking the published artifacts. Gated on + // `CI` to keep contributor builds quiet. + if std::env::var_os("CI").is_some() { + match &described { + Ok(describe) => println!("cargo:warning=CLAI_GIT_DESCRIBE={describe}"), + Err(reason) => println!("cargo:warning=CLAI_GIT_DESCRIBE unset: {reason}"), } } @@ -31,3 +37,34 @@ fn main() { tauri_build::build() } + +/// `git describe` for HEAD, or the reason it produced nothing usable. +/// +/// The error side exists purely so the `CI` branch above can log *why* no +/// version was baked; every failure mode is a legitimate build configuration +/// (no git, no `.git`, no commits) and none of them is fatal. +fn describe_head() -> Result { + let output = Command::new("git") + .args(["describe", "--tags", "--always", "--dirty"]) + .output() + .map_err(|err| format!("could not run git: {err}"))?; + if !output.status.success() { + return Err(format!( + "git describe failed ({}): {}", + output.status, + one_line(&String::from_utf8_lossy(&output.stderr)) + )); + } + let describe = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if describe.is_empty() { + return Err("git describe printed nothing".to_string()); + } + Ok(describe) +} + +/// Collapse whitespace so multi-line git stderr survives the trip through +/// `cargo:warning`: cargo shows only the first line of the value and drops the +/// rest without `-vv`. +fn one_line(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +}