Files
jellytau/scripts/set-version.test.ts
T
dtourolleandClaude Opus 5 3619f71aba
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 7m36s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m57s
Build & Release / Build Linux (push) Successful in 20m4s
Build & Release / Build Windows (push) Successful in 8m42s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 17s
build: make the git tag the single source of truth for the version (DR-153)
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore produced an installer
named for the tag wrapped around package metadata naming the previous release,
and the Linux job, which had no version step at all, shipped whatever happened
to be committed.

scripts/set-version.sh now writes all four from one argument and is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.

The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.

Two edge cases the previous inline version got wrong:

- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
  The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
  is still a full ref. That reached the validator verbatim and would have failed
  every untagged Android build; a non-tag ref now falls back to git describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:21:58 +02:00

170 lines
6.2 KiB
TypeScript

/**
* Guards for scripts/set-version.sh — the release version stamper.
*
* TRACES: DR-153 | UT-150
*
* These run the real script against a throwaway copy of the manifests, because
* the failure modes are all in the shell, not in any TS logic: a regex that also
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
* a CI ref reaching the validator verbatim.
*
* The versionCode formula matters most. Android refuses an update whose code is
* lower than the installed one, and builds already in the field shipped code
* 1000 — so any formula that can emit a smaller number for a *newer* release
* bricks updates for those users, silently and irreversibly.
*/
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const script = path.join(repoRoot, "scripts", "set-version.sh");
let tmp: string;
/** A minimal repo skeleton: just the files the script rewrites. */
function seed(dir: string) {
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
);
fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"),
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
);
// A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"),
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
);
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"),
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
);
fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n"
);
}
function run(version: string, dir = tmp) {
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
cwd: dir,
encoding: "utf-8",
});
}
function read(rel: string): string {
return fs.readFileSync(path.join(tmp, rel), "utf-8");
}
function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match(
/^tauri\.android\.versionCode=(\d+)$/m
);
return m ? Number(m[1]) : NaN;
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
seed(tmp);
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("set-version.sh", () => {
it("stamps the version into all four manifests", () => {
run("0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
});
it("accepts a leading v, as git tags are written", () => {
run("v0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
});
// The regression that motivates anchoring the patterns.
it("does not rewrite dependency versions", () => {
run("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
});
describe("Android versionCode", () => {
// Codes below 1000 are already in the field; a newer release must never
// produce a smaller number than an older one.
it("clears the 1000 floor shipped by earlier builds", () => {
run("0.0.1");
expect(versionCode()).toBeGreaterThan(1000);
});
it("uses 1000 + major*10000 + minor*100 + patch", () => {
const cases: Array<[string, number]> = [
["0.0.14", 1014],
["0.0.15", 1015],
["0.1.0", 1100],
["0.4.8", 1408],
["0.5.0", 1500],
["1.0.0", 11000],
];
for (const [version, code] of cases) {
seed(tmp);
run(version);
expect(versionCode(), `versionCode for ${version}`).toBe(code);
}
});
it("increases monotonically across an upgrade sequence", () => {
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
const codes = ordered.map((v) => {
seed(tmp);
run(v);
return versionCode();
});
const sorted = [...codes].sort((a, b) => a - b);
expect(codes).toEqual(sorted);
expect(new Set(codes).size).toBe(codes.length);
});
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
// stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1");
expect(versionCode()).toBe(1600);
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
});
});
describe("input validation", () => {
it("rejects a malformed version without writing anything", () => {
expect(() => run("not-a-version")).toThrow();
// The manifests must be untouched, not half-written.
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
});
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
// that is still a full ref, and must not fail the job.
it("falls back to a dev version when handed a non-tag ref", () => {
const out = run("refs/heads/master");
expect(out).not.toMatch(/refs\/heads/);
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
});
});
});