Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
229 lines
8.4 KiB
TypeScript
229 lines
8.4 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",
|
|
);
|
|
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, "packaging", "arch", "PKGBUILD"),
|
|
["pkgname=jellytau", "pkgver=0.0.1", "pkgrel=3", 'pkgdesc="x"', ""].join("\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", () => {
|
|
// A newer release must never produce a smaller number than an older one, or
|
|
// Android refuses the update. The floor tracks the highest code actually in
|
|
// the field, which is NOT the same as the highest this formula has produced:
|
|
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
|
|
// the `minor*100` formula that replaced it yields only 1502 for that same
|
|
// version — so every 0.5.x release built from it was an un-installable
|
|
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
|
|
it("clears the highest code shipped by earlier builds", () => {
|
|
run("0.0.1");
|
|
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
|
|
expect(versionCode()).toBeGreaterThan(5002);
|
|
});
|
|
|
|
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
|
|
run("0.5.3");
|
|
expect(versionCode()).toBeGreaterThan(5002);
|
|
});
|
|
|
|
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
|
|
const cases: Array<[string, number]> = [
|
|
["0.0.14", 10014],
|
|
["0.0.15", 10015],
|
|
["0.1.0", 11000],
|
|
["0.4.8", 14008],
|
|
["0.5.0", 15000],
|
|
["0.5.3", 15003],
|
|
["1.0.0", 1010000],
|
|
];
|
|
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(16000);
|
|
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");
|
|
});
|
|
});
|
|
|
|
// The Arch package is built by makepkg, not the tauri bundler, so its version
|
|
// lives in a file the rest of the release path never touches. It sat at
|
|
// 0.0.18 while the tree was on 0.8.x — makepkg happily produced a package
|
|
// whose version bore no relation to the source it was built from, which is
|
|
// the exact failure this script was written to prevent.
|
|
describe("PKGBUILD", () => {
|
|
it("stamps pkgver and resets pkgrel", () => {
|
|
run("0.9.0");
|
|
const pkgbuild = read("packaging/arch/PKGBUILD");
|
|
expect(pkgbuild).toMatch(/^pkgver=0\.9\.0$/m);
|
|
// A new upstream version starts its packaging revisions over.
|
|
expect(pkgbuild).toMatch(/^pkgrel=1$/m);
|
|
});
|
|
|
|
it("converts a dev version into a pkgver Arch accepts", () => {
|
|
// pkgver may not contain a hyphen — it is the pkgver/pkgrel separator.
|
|
run("0.9.0-3-gabc1234");
|
|
const pkgbuild = read("packaging/arch/PKGBUILD");
|
|
const match = pkgbuild.match(/^pkgver=(.*)$/m);
|
|
expect(match).not.toBeNull();
|
|
expect(match![1]).not.toContain("-");
|
|
expect(match![1]).toBe("0.9.0.r3.gabc1234");
|
|
});
|
|
});
|
|
});
|