438 lines
16 KiB
JavaScript
438 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
rmSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test } from "node:test";
|
|
|
|
const source = readFileSync(
|
|
join(dirname(fileURLToPath(import.meta.url)), "prepare-release.ps1"),
|
|
"utf8",
|
|
).replace(/^\ufeff/, "");
|
|
const entry = source.lastIndexOf("try {\n Push-Location $RepoRoot");
|
|
const crlfEntry = source.lastIndexOf("try {\r\n Push-Location $RepoRoot");
|
|
const entryOffset = Math.max(entry, crlfEntry);
|
|
assert.ok(entryOffset > 0);
|
|
|
|
function run(cwd, command, args, ok = true) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
timeout: 60000,
|
|
windowsHide: true,
|
|
});
|
|
if (ok)
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`${command}: ${result.stdout}\n${result.stderr}`,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function fixture(t, build = "") {
|
|
const root = mkdtempSync(join(tmpdir(), "proxywarden-release-test-"));
|
|
t.after(() => {
|
|
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep));
|
|
assert.ok(root.includes("proxywarden-release-test-"));
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const repo = join(root, "repo");
|
|
mkdirSync(repo);
|
|
const write = (path, text) => {
|
|
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
|
writeFileSync(join(repo, path), text);
|
|
};
|
|
const git = (...args) => run(repo, "git", args).stdout.trim();
|
|
write("package.json", '{"name":"proxywarden","version":"1.2.0"}\n');
|
|
write(
|
|
"package-lock.json",
|
|
'{"name":"proxywarden","version":"1.2.0","packages":{"":{"name":"proxywarden","version":"1.2.0"}}}\n',
|
|
);
|
|
write("src-tauri/tauri.conf.json", '{"version":"1.2.0"}\n');
|
|
write(
|
|
"src-tauri/Cargo.toml",
|
|
'[package]\nname = "proxywarden"\nversion = "1.2.0"\n',
|
|
);
|
|
write(
|
|
"src-tauri/Cargo.lock",
|
|
'[[package]]\nname = "proxywarden"\nversion = "1.2.0"\n',
|
|
);
|
|
write(".gitignore", "node_modules/\nsrc-tauri/target/\nreleases/\n");
|
|
for (const cli of [
|
|
"typescript/bin/tsc",
|
|
"vite/bin/vite.js",
|
|
"@tauri-apps/cli/tauri.js",
|
|
"prettier/bin/prettier.cjs",
|
|
"eslint/bin/eslint.js",
|
|
"vitest/vitest.mjs",
|
|
])
|
|
write(`node_modules/${cli}`, "fixture");
|
|
// Replace only expensive checks/build in this isolated copy. Git/version/artifact/push code is real.
|
|
const stub = `
|
|
function Invoke-ReleaseChecks {}
|
|
function Invoke-ReleaseBuild {
|
|
${build}
|
|
$output = Join-Path $BundleRoot 'nsis'
|
|
New-Item -ItemType Directory -Path $output -Force | Out-Null
|
|
[IO.File]::WriteAllText((Join-Path $output "ProxyWarden_$($targetVersion)_x64-setup.exe"), 'test artifact')
|
|
}
|
|
`;
|
|
write(
|
|
"scripts/prepare-release.ps1",
|
|
"\ufeff" + source.slice(0, entryOffset) + stub + source.slice(entryOffset),
|
|
);
|
|
git("init", "-b", "master");
|
|
git("config", "user.name", "Release Test");
|
|
git("config", "user.email", "release-test@example.invalid");
|
|
git("config", "core.autocrlf", "false");
|
|
git("add", ".");
|
|
git("commit", "-m", "initial");
|
|
const remote = join(root, "origin.git");
|
|
run(root, "git", ["init", "--bare", remote]);
|
|
git("remote", "add", "origin", remote);
|
|
git("push", "-u", "origin", "master");
|
|
const release = (...args) =>
|
|
run(
|
|
repo,
|
|
"pwsh",
|
|
[
|
|
"-NoProfile",
|
|
"-File",
|
|
"scripts/prepare-release.ps1",
|
|
"-Publish",
|
|
...args,
|
|
],
|
|
false,
|
|
);
|
|
const manifest = () =>
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(repo, "releases/proxywarden-v1.2.1/release-manifest.json"),
|
|
"utf8",
|
|
).replace(/^\ufeff/, ""),
|
|
);
|
|
return { root, repo, remote, git, write, release, manifest };
|
|
}
|
|
|
|
test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => {
|
|
const f = fixture(t);
|
|
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
|
|
const before = f.git("status", "--porcelain");
|
|
const head = f.git("rev-parse", "HEAD");
|
|
const result = f.release("-PlanOnly");
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const plan = JSON.parse(result.stdout);
|
|
assert.equal(plan.changed, false);
|
|
assert.equal(plan.details.targetVersion, "1.2.1");
|
|
assert.equal(f.git("status", "--porcelain"), before);
|
|
assert.equal(f.git("rev-parse", "HEAD"), head);
|
|
const replacement = f.release("-Version", "1.2.0", "-Replace", "-PlanOnly");
|
|
assert.equal(replacement.status, 0, replacement.stderr);
|
|
const replacementPlan = JSON.parse(replacement.stdout);
|
|
assert.equal(replacementPlan.changed, false);
|
|
assert.equal(replacementPlan.details.replace, true);
|
|
assert.equal(
|
|
replacementPlan.details.git.replaceOnlyVersionTagWithLease,
|
|
true,
|
|
);
|
|
assert.equal(f.git("status", "--porcelain"), before);
|
|
assert.equal(f.git("rev-parse", "HEAD"), head);
|
|
});
|
|
|
|
test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => {
|
|
const f = fixture(t);
|
|
f.write("feature.txt", "new feature");
|
|
const result = f.release("-Version", "1.2.1");
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
const head = f.git("rev-parse", "HEAD");
|
|
assert.equal(f.git("rev-parse", "v1.2.1^{commit}"), head);
|
|
assert.equal(
|
|
f.git("ls-remote", "origin", "refs/heads/master").split(/\s/)[0],
|
|
head,
|
|
);
|
|
assert.equal(f.git("status", "--porcelain"), "");
|
|
assert.equal(f.manifest().gitCommit, head);
|
|
assert.equal(f.manifest().gitRelease.status, "pushed");
|
|
assert.equal(f.manifest().artifacts.length, 1);
|
|
assert.match(
|
|
readFileSync(join(f.repo, "src-tauri/Cargo.lock"), "utf8"),
|
|
/version = "1.2.1"/,
|
|
);
|
|
const repeat = f.release("-Version", "1.2.1");
|
|
assert.notEqual(repeat.status, 0);
|
|
assert.equal(f.git("rev-parse", "HEAD"), head);
|
|
});
|
|
|
|
test("failed build creates no commit/tag/push and preserves existing staging", (t) => {
|
|
const f = fixture(t, "throw 'Synthetic build failure'");
|
|
f.write("staged.txt", "staged");
|
|
f.git("add", "staged.txt");
|
|
const index = f.git("write-tree"),
|
|
head = f.git("rev-parse", "HEAD");
|
|
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
|
|
assert.equal(f.git("write-tree"), index);
|
|
assert.equal(f.git("rev-parse", "HEAD"), head);
|
|
assert.equal(f.git("tag", "--list"), "");
|
|
});
|
|
|
|
for (const remoteOnly of [false, true]) {
|
|
test(`replacement rebuilds the same version and preserves the old folder (remote-only tag: ${remoteOnly})`, (t) => {
|
|
const f = fixture(t);
|
|
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
|
const oldTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
|
const oldCommit = f.git("rev-parse", "HEAD");
|
|
const oldManifest = f.manifest();
|
|
if (remoteOnly) f.git("tag", "-d", "v1.2.1");
|
|
f.write("feature.txt", "updated before publishing");
|
|
|
|
const result = f.release("-Version", "1.2.1", "-Replace");
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
const newTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
|
assert.notEqual(newTag, oldTag);
|
|
assert.equal(
|
|
f.git("rev-parse", "v1.2.1^{commit}"),
|
|
f.git("rev-parse", "HEAD"),
|
|
);
|
|
assert.equal(
|
|
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
|
newTag,
|
|
);
|
|
assert.equal(f.git("rev-parse", "HEAD~1"), oldCommit);
|
|
assert.equal(f.manifest().gitRelease.previousRemoteTag, oldTag);
|
|
assert.equal(f.manifest().gitRelease.status, "pushed");
|
|
const backups = readdirSync(join(f.repo, "releases")).filter((name) =>
|
|
name.startsWith("proxywarden-v1.2.1-replaced-"),
|
|
);
|
|
assert.equal(backups.length, 1);
|
|
assert.deepEqual(
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(f.repo, "releases", backups[0], "release-manifest.json"),
|
|
"utf8",
|
|
),
|
|
),
|
|
oldManifest,
|
|
);
|
|
assert.equal(
|
|
readFileSync(
|
|
join(
|
|
f.repo,
|
|
"releases",
|
|
backups[0],
|
|
"artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
|
|
),
|
|
"utf8",
|
|
),
|
|
"test artifact",
|
|
);
|
|
assert.equal(f.git("status", "--porcelain"), "");
|
|
});
|
|
}
|
|
|
|
test("failed replacement build preserves the previous release and refs", (t) => {
|
|
const f = fixture(
|
|
t,
|
|
"if ($Replace) { throw 'Synthetic replacement build failure' }",
|
|
);
|
|
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
|
const oldManifest = f.manifest();
|
|
const oldRefs = f.git("show-ref");
|
|
f.write("feature.txt", "work in progress");
|
|
const result = f.release("-Version", "1.2.1", "-Replace");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /Synthetic replacement build failure/);
|
|
assert.equal(f.git("show-ref"), oldRefs);
|
|
assert.deepEqual(f.manifest(), oldManifest);
|
|
assert.deepEqual(readdirSync(join(f.repo, "releases")), [
|
|
"proxywarden-v1.2.1",
|
|
]);
|
|
});
|
|
|
|
test("a failed replacement push resumes using the original tag lease", (t) => {
|
|
const f = fixture(t);
|
|
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
|
const oldRefs = f.git("ls-remote", "origin");
|
|
f.write("feature.txt", "replacement");
|
|
const hook = join(f.remote, "hooks/pre-receive");
|
|
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
|
|
const result = f.release("-Version", "1.2.1", "-Replace");
|
|
assert.notEqual(result.status, 0);
|
|
assert.equal(f.manifest().gitRelease.status, "pending-push");
|
|
assert.equal(f.git("ls-remote", "origin"), oldRefs);
|
|
const replacementTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
|
rmSync(hook);
|
|
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
|
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
|
|
assert.equal(f.manifest().gitRelease.status, "pushed");
|
|
assert.equal(
|
|
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
|
replacementTag,
|
|
);
|
|
const repeat = f.release("-Version", "1.2.1", "-Resume");
|
|
assert.equal(repeat.status, 0, repeat.stdout + repeat.stderr);
|
|
});
|
|
|
|
test("replacement never forces the branch when it advances during the build", (t) => {
|
|
const f = fixture(
|
|
t,
|
|
`if ($Replace) {
|
|
$otherCommit = 'Concurrent remote commit' | & git commit-tree 'HEAD^{tree}' -p HEAD
|
|
Invoke-Git @('push', 'origin', "${"$"}{otherCommit}:refs/heads/master") | Out-Null
|
|
}`,
|
|
);
|
|
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
|
const oldTag = f.git("ls-remote", "origin", "refs/tags/v1.2.1");
|
|
f.write("feature.txt", "replacement");
|
|
const result = f.release("-Version", "1.2.1", "-Replace");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /non-fast-forward|fetch first/);
|
|
const remoteBranch = f
|
|
.git("ls-remote", "origin", "refs/heads/master")
|
|
.split(/\s/)[0];
|
|
assert.notEqual(remoteBranch, f.git("rev-parse", "HEAD"));
|
|
assert.equal(
|
|
f.git("show", "-s", "--format=%s", remoteBranch),
|
|
"Concurrent remote commit",
|
|
);
|
|
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), oldTag);
|
|
});
|
|
|
|
test("a concurrent remote tag change is preserved, including on Resume", (t) => {
|
|
const f = fixture(
|
|
t,
|
|
`if ($Replace) {
|
|
$otherCommit = Invoke-Git @('rev-parse', 'HEAD~1')
|
|
Invoke-Git @('--git-dir', (Join-Path $RepoRoot '../origin.git'), 'update-ref', 'refs/tags/v1.2.1', $otherCommit) | Out-Null
|
|
}`,
|
|
);
|
|
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
|
const oldBranch = f.git("ls-remote", "origin", "refs/heads/master");
|
|
const concurrentTag = f.git("rev-parse", "HEAD~1");
|
|
f.write("feature.txt", "replacement");
|
|
const result = f.release("-Version", "1.2.1", "-Replace");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /stale info/);
|
|
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
|
assert.notEqual(resumed.status, 0);
|
|
assert.match(resumed.stderr, /stale info/);
|
|
assert.equal(
|
|
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
|
concurrentTag,
|
|
);
|
|
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), oldBranch);
|
|
});
|
|
|
|
test("a concurrent local tag change is not overwritten by replacement", (t) => {
|
|
const f = fixture(
|
|
t,
|
|
"if ($Replace) { Invoke-Git @('tag', '-f', 'v1.2.1', 'HEAD~1') | Out-Null }",
|
|
);
|
|
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
|
const oldRemote = f.git("ls-remote", "origin");
|
|
const concurrentTag = f.git("rev-parse", "HEAD~1");
|
|
f.write("feature.txt", "replacement");
|
|
const result = f.release("-Version", "1.2.1", "-Replace");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /Local version tag changed/);
|
|
assert.equal(f.git("rev-parse", "refs/tags/v1.2.1"), concurrentTag);
|
|
assert.equal(f.git("ls-remote", "origin"), oldRemote);
|
|
});
|
|
|
|
test("source edit during build refuses to tag an artifact from another tree", (t) => {
|
|
const f = fixture(
|
|
t,
|
|
"[IO.File]::WriteAllText((Join-Path $RepoRoot 'concurrent.txt'), 'changed during build')",
|
|
);
|
|
const head = f.git("rev-parse", "HEAD");
|
|
const result = f.release("-Version", "1.2.1");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /changed during the build/);
|
|
assert.match(result.stderr, /concurrent\.txt/);
|
|
assert.equal(f.git("rev-parse", "HEAD"), head);
|
|
assert.equal(f.git("tag", "--list"), "");
|
|
});
|
|
|
|
test("failed atomic push keeps artifacts and resumes without rebuilding", (t) => {
|
|
const f = fixture(t);
|
|
const hook = join(f.remote, "hooks/pre-receive");
|
|
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
|
|
const before = f.git("ls-remote", "origin", "refs/heads/master");
|
|
const failed = f.release("-Version", "1.2.1");
|
|
assert.notEqual(failed.status, 0);
|
|
assert.equal(f.manifest().gitRelease.status, "pending-push");
|
|
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), before);
|
|
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), "");
|
|
const commit = f.git("rev-parse", "HEAD");
|
|
rmSync(hook);
|
|
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
|
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
|
|
assert.equal(f.git("rev-parse", "HEAD"), commit);
|
|
assert.equal(f.manifest().gitRelease.status, "pushed");
|
|
f.write(
|
|
"releases/proxywarden-v1.2.1/artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
|
|
"tampered",
|
|
);
|
|
assert.notEqual(f.release("-Version", "1.2.1", "-Resume").status, 0);
|
|
});
|
|
|
|
test("remote-only version tag and diverged branch are refused before version edits", (t) => {
|
|
const f = fixture(t);
|
|
f.git("tag", "v1.2.1");
|
|
f.git("push", "origin", "refs/tags/v1.2.1");
|
|
f.git("tag", "-d", "v1.2.1");
|
|
const version = readFileSync(join(f.repo, "package.json"), "utf8");
|
|
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
|
|
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
|
|
const clone = join(f.root, "other");
|
|
run(f.root, "git", ["clone", "--branch", "master", f.remote, clone]);
|
|
run(clone, "git", ["config", "user.name", "Other"]);
|
|
run(clone, "git", ["config", "user.email", "other@example.invalid"]);
|
|
writeFileSync(join(clone, "remote-change.txt"), "remote");
|
|
run(clone, "git", ["add", "."]);
|
|
run(clone, "git", ["commit", "-m", "remote change"]);
|
|
run(clone, "git", ["push"]);
|
|
const result = f.release("-Version", "1.2.2");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /Integrate them before releasing/);
|
|
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
|
|
});
|
|
|
|
test("invalid Windows versions and mismatched Cargo.lock fail without mutations", (t) => {
|
|
const f = fixture(t);
|
|
for (const version of ["01.2.3", "1.2.65536", "1.2.3-rc.1"])
|
|
assert.notEqual(f.release("-Version", version, "-PlanOnly").status, 0);
|
|
f.write(
|
|
"src-tauri/Cargo.lock",
|
|
'[[package]]\nname = "proxywarden"\nversion = "0.0.0"\n',
|
|
);
|
|
const result = f.release("-PlanOnly");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /Version mismatch/);
|
|
});
|
|
|
|
test("unreachable origin reports the Git cause and diagnostic command without a PowerShell stack", (t) => {
|
|
const f = fixture(t);
|
|
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
|
|
const head = f.git("rev-parse", "HEAD");
|
|
const version = readFileSync(join(f.repo, "package.json"), "utf8");
|
|
const result = f.release("-Version", "1.2.1");
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /git ls-remote origin/);
|
|
assert.match(result.stderr, /does not appear to be a git repository/);
|
|
assert.doesNotMatch(
|
|
result.stderr,
|
|
/prepare-release\.ps1:\d|ScriptStackTrace|Line \|/,
|
|
);
|
|
assert.equal(f.git("rev-parse", "HEAD"), head);
|
|
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
|
|
});
|