Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m49s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 24s
pkgver sat at 0.0.18 while the tree was on 0.8.x, because the Arch package is built by makepkg rather than the tauri bundler and set-version.sh never touched it. makepkg produced a package whose version bore no relation to the source it was built from — the exact failure that script exists to prevent, in the one file it had missed. Dev versions are converted to a pkgver Arch accepts: a hyphen separates pkgver from pkgrel, so 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234. pkgrel resets to 1, since a new upstream version restarts its packaging revisions. Also installs LICENSE into /usr/share/licenses — MIT is not in Arch's common licences, so a package under it has to carry the text. Arch is not part of the automated release (build-release.yml covers linux, windows and android), so v0.9.0 is unaffected; this applies to anyone building the package by hand.
212 lines
8.3 KiB
TypeScript
212 lines
8.3 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");
|
|
});
|
|
});
|
|
});
|