Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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 <file>` 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
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<version>-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:
Expand All @@ -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]
Expand Down
38 changes: 38 additions & 0 deletions scripts/assert-pristine-tree.sh
Original file line number Diff line number Diff line change
@@ -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 `<version>-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 "<when this ran>"
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
55 changes: 46 additions & 9 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
}
}

Expand All @@ -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<String, String> {
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::<Vec<_>>().join(" ")
}
Loading