/** * 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"); }); }); });