@rightkit/release 0.2.37 → 0.2.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-invocation-contract.test.mjs +96 -0
- package/build-release.mjs +24 -0
- package/package.json +1 -1
- package/release-cli-contract.test.mjs +12 -0
- package/release-invocation.mjs +77 -0
- package/right-suite-contract.test.mjs +1 -1
- package/rightkit-versions.json +1 -1
- package/upload-release.mjs +2 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
|
|
9
|
+
|
|
10
|
+
function git(cwd, ...args) {
|
|
11
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeRepo() {
|
|
15
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "right-release-invocation-"));
|
|
16
|
+
git(root, "init", "--initial-branch", "main");
|
|
17
|
+
git(root, "config", "user.email", "release-test@example.com");
|
|
18
|
+
git(root, "config", "user.name", "Release Test");
|
|
19
|
+
mkdirSync(path.join(root, "src"));
|
|
20
|
+
mkdirSync(path.join(root, "tests"));
|
|
21
|
+
mkdirSync(path.join(root, "docs"));
|
|
22
|
+
writeFileSync(path.join(root, "package.json"), "{}\n");
|
|
23
|
+
writeFileSync(path.join(root, "src", "app.js"), "export const app = true;\n");
|
|
24
|
+
writeFileSync(path.join(root, "tests", "app.test.js"), "// test\n");
|
|
25
|
+
writeFileSync(path.join(root, "docs", "notes.md"), "notes\n");
|
|
26
|
+
git(root, "add", ".");
|
|
27
|
+
git(root, "commit", "-m", "initial");
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const buildInputs = {
|
|
32
|
+
include: ["package.json", "src/**"],
|
|
33
|
+
exclude: ["**/*.test.*", "**/tests/**"],
|
|
34
|
+
json: {
|
|
35
|
+
"package.json": [["dependencies"], ["scripts", "build"]],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test("rejects release invocation from a linked Git worktree", () => {
|
|
40
|
+
const primary = makeRepo();
|
|
41
|
+
const linked = path.join(path.dirname(primary), `${path.basename(primary)}-linked`);
|
|
42
|
+
git(primary, "worktree", "add", "--detach", linked);
|
|
43
|
+
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => assertPrimaryReleaseCheckout(linked),
|
|
46
|
+
/must be invoked from the primary Git worktree/i,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("allows release invocation from the primary Git checkout", () => {
|
|
51
|
+
const primary = makeRepo();
|
|
52
|
+
|
|
53
|
+
assert.doesNotThrow(() => assertPrimaryReleaseCheckout(primary));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("reports dirty files that can change the packaged app", () => {
|
|
57
|
+
const primary = makeRepo();
|
|
58
|
+
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
59
|
+
writeFileSync(path.join(primary, "src", "new-runtime.js"), "export {};\n");
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), [
|
|
62
|
+
"src/app.js",
|
|
63
|
+
"src/new-runtime.js",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("ignores dirty files outside the packaged app input set", () => {
|
|
68
|
+
const primary = makeRepo();
|
|
69
|
+
writeFileSync(path.join(primary, "docs", "notes.md"), "changed notes\n");
|
|
70
|
+
writeFileSync(path.join(primary, "tests", "app.test.js"), "// changed test\n");
|
|
71
|
+
writeFileSync(path.join(primary, "scratch.txt"), "evaluation output\n");
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("checks only build-relevant fields in mixed-purpose JSON manifests", () => {
|
|
77
|
+
const primary = makeRepo();
|
|
78
|
+
writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ scripts: { bakeoff: "node eval.mjs" } })}\n`);
|
|
79
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), []);
|
|
80
|
+
|
|
81
|
+
writeFileSync(path.join(primary, "package.json"), `${JSON.stringify({ dependencies: { tauri: "2.0.0" } })}\n`);
|
|
82
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs), ["package.json"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("does not report a locally dirty file whose bytes already equal the release commit", () => {
|
|
86
|
+
const primary = makeRepo();
|
|
87
|
+
const localHead = git(primary, "rev-parse", "HEAD");
|
|
88
|
+
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
89
|
+
git(primary, "add", "src/app.js");
|
|
90
|
+
git(primary, "commit", "-m", "release change");
|
|
91
|
+
const releaseCommit = git(primary, "rev-parse", "HEAD");
|
|
92
|
+
git(primary, "checkout", localHead);
|
|
93
|
+
writeFileSync(path.join(primary, "src", "app.js"), "export const app = false;\n");
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(dirtyBuildInputs(primary, buildInputs, releaseCommit), []);
|
|
96
|
+
});
|
package/build-release.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { spawn, spawnSync } from "node:child_process";
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
+
import { assertPrimaryReleaseCheckout, dirtyBuildInputs } from "./release-invocation.mjs";
|
|
23
24
|
import { cacheFingerprint, commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, watchProgress } from "./release-state.mjs";
|
|
24
25
|
|
|
25
26
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -42,6 +43,7 @@ if (platform !== "win" && platform !== "mac") fail("--platform must be win or ma
|
|
|
42
43
|
|
|
43
44
|
const invocationRoot = path.dirname(path.resolve(configName));
|
|
44
45
|
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
46
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
45
47
|
const relativeConfig = path.relative(repoRoot, path.resolve(configName)).replaceAll("\\", "/");
|
|
46
48
|
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
47
49
|
const vaultRoot = resolveReleaseLayout({ repoRoot, configPath: path.resolve(configName) }).vaultRoot;
|
|
@@ -80,6 +82,10 @@ try {
|
|
|
80
82
|
}
|
|
81
83
|
const target = config.targets?.[platform];
|
|
82
84
|
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
85
|
+
const dirtyInputs = dirtyBuildInputs(repoRoot, repoBuildInputs(repoRoot, invocationRoot, relativeConfig, target.buildInputs ?? config.buildInputs), commit);
|
|
86
|
+
if (dirtyInputs.length > 0) {
|
|
87
|
+
fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
88
|
+
}
|
|
83
89
|
const requiredInputs = inputPaths(appRoot, worktreeConfigPath, target);
|
|
84
90
|
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
85
91
|
const inputHashes = hashInputs(requiredInputs);
|
|
@@ -182,6 +188,24 @@ function inputPaths(appRoot, configPath, target) {
|
|
|
182
188
|
return required;
|
|
183
189
|
}
|
|
184
190
|
|
|
191
|
+
function repoBuildInputs(repoRoot, appRoot, relativeConfig, configured) {
|
|
192
|
+
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
193
|
+
fail("release config must declare non-empty buildInputs.include paths");
|
|
194
|
+
}
|
|
195
|
+
const appPrefix = path.relative(repoRoot, appRoot).replaceAll("\\", "/");
|
|
196
|
+
const qualify = (pattern) => [appPrefix, pattern.replaceAll("\\", "/").replace(/^\.\//, "")].filter(Boolean).join("/");
|
|
197
|
+
return {
|
|
198
|
+
include: [
|
|
199
|
+
relativeConfig,
|
|
200
|
+
qualify("package.json"),
|
|
201
|
+
qualify("pnpm-lock.yaml"),
|
|
202
|
+
...configured.include.map(qualify),
|
|
203
|
+
],
|
|
204
|
+
exclude: (configured.exclude ?? []).map(qualify),
|
|
205
|
+
json: Object.fromEntries(Object.entries(configured.json ?? {}).map(([file, fields]) => [qualify(file), fields])),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
function hashInputs(files) {
|
|
186
210
|
return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
|
|
187
211
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.38",
|
|
4
4
|
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,18 @@ test("build is tier-neutral and rejects a tier before touching a repository", ()
|
|
|
34
34
|
assert.match(result.stderr, /build is tier-neutral/i);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test("build enforces primary-checkout and build-input cleanliness guards", () => {
|
|
38
|
+
const source = readFileSync(build, "utf8");
|
|
39
|
+
assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
|
|
40
|
+
assert.match(source, /dirtyBuildInputs\(repoRoot,/);
|
|
41
|
+
assert.match(source, /dirty files can change the packaged app/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("upload enforces the same primary-checkout vault", () => {
|
|
45
|
+
const source = readFileSync(upload, "utf8");
|
|
46
|
+
assert.match(source, /assertPrimaryReleaseCheckout\(repoRoot\)/);
|
|
47
|
+
});
|
|
48
|
+
|
|
37
49
|
test("upload requires an explicit patch or update tier before reading a release", () => {
|
|
38
50
|
const result = spawnSync(process.execPath, [upload, "--platform", "win", "--release", "fixture-1.0.0-deadbeef"], {
|
|
39
51
|
cwd: mkdtempSync(path.join(os.tmpdir(), "right-upload-cli-")),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function git(cwd, args) {
|
|
6
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
|
|
7
|
+
if (result.status !== 0) {
|
|
8
|
+
throw new Error(`git ${args.join(" ")} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
9
|
+
}
|
|
10
|
+
return result.stdout;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function canonical(file) {
|
|
14
|
+
return path.normalize(realpathSync(file)).toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
18
|
+
const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
19
|
+
const primary = worktrees.match(/^worktree (.+)$/m)?.[1];
|
|
20
|
+
if (!primary) throw new Error("unable to resolve the primary Git worktree");
|
|
21
|
+
if (canonical(repoRoot) !== canonical(primary)) {
|
|
22
|
+
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pathspec(pattern, exclude = false) {
|
|
27
|
+
const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
28
|
+
return `:(top,${exclude ? "exclude," : ""}glob)${normalized}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsonProjectionChanged(repoRoot, file, fields, baseline) {
|
|
32
|
+
try {
|
|
33
|
+
const working = JSON.parse(readFileSync(path.join(repoRoot, file), "utf8"));
|
|
34
|
+
const committed = JSON.parse(git(repoRoot, ["show", `${baseline}:${file}`]));
|
|
35
|
+
const project = (value) => fields.map((segments) => segments.reduce((current, segment) => current?.[segment], value));
|
|
36
|
+
return JSON.stringify(project(working)) !== JSON.stringify(project(committed));
|
|
37
|
+
} catch {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fileChangedFromBaseline(repoRoot, file, baseline) {
|
|
43
|
+
const workingPath = path.join(repoRoot, file);
|
|
44
|
+
if (!existsSync(workingPath)) return true;
|
|
45
|
+
try {
|
|
46
|
+
const workingHash = git(repoRoot, ["hash-object", "--", file]).trim();
|
|
47
|
+
const baselineHash = git(repoRoot, ["rev-parse", `${baseline}:${file}`]).trim();
|
|
48
|
+
return workingHash !== baselineHash;
|
|
49
|
+
} catch {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function dirtyBuildInputs(repoRoot, inputs, baseline = "HEAD") {
|
|
55
|
+
if (!Array.isArray(inputs?.include) || inputs.include.length === 0) {
|
|
56
|
+
throw new Error("release config must declare non-empty buildInputs.include paths");
|
|
57
|
+
}
|
|
58
|
+
const specs = [
|
|
59
|
+
...inputs.include.map((pattern) => pathspec(pattern)),
|
|
60
|
+
...(inputs.exclude ?? []).map((pattern) => pathspec(pattern, true)),
|
|
61
|
+
];
|
|
62
|
+
const output = git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...specs]);
|
|
63
|
+
const records = output.split("\0");
|
|
64
|
+
const files = [];
|
|
65
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
66
|
+
const record = records[index];
|
|
67
|
+
if (!record) continue;
|
|
68
|
+
const status = record.slice(0, 2);
|
|
69
|
+
files.push(record.slice(3).replaceAll("\\", "/"));
|
|
70
|
+
if (/[RC]/.test(status)) index += 1;
|
|
71
|
+
}
|
|
72
|
+
return [...new Set(files)]
|
|
73
|
+
.filter((file) => inputs.json?.[file]
|
|
74
|
+
? jsonProjectionChanged(repoRoot, file, inputs.json[file], baseline)
|
|
75
|
+
: fileChangedFromBaseline(repoRoot, file, baseline))
|
|
76
|
+
.sort();
|
|
77
|
+
}
|
|
@@ -317,7 +317,7 @@ test("Cargo version contract rejects staged versions that mismatch canonical man
|
|
|
317
317
|
test("RightKit exposes one current version manifest", () => {
|
|
318
318
|
assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
|
|
319
319
|
assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
|
|
320
|
-
assert.equal(versions.npm["@rightkit/release"], "0.2.
|
|
320
|
+
assert.equal(versions.npm["@rightkit/release"], "0.2.38");
|
|
321
321
|
assert.equal(versions.npm["@rightkit/legal"], "0.2.0");
|
|
322
322
|
assert.equal(versions.npm["@rightkit/license"], "0.1.5");
|
|
323
323
|
assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
|
package/rightkit-versions.json
CHANGED
package/upload-release.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, wr
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
7
8
|
import { runUploadStateMachine, verifySealedRelease } from "./release-state.mjs";
|
|
8
9
|
|
|
9
10
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -34,6 +35,7 @@ if (!releaseId) fail("--release is required");
|
|
|
34
35
|
if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
|
|
35
36
|
|
|
36
37
|
const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
38
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
37
39
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
38
40
|
const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir);
|
|
39
41
|
const sealed = verifySealedRelease(sealedDir);
|