diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..090f0d1 --- /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 a016061..dbc530c 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 0000000..45f5781 --- /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 0157d16..c761519 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(" ") +}