@rightkit/release 0.2.60 → 0.2.62
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-release.mjs +43 -16
- package/build-release.test.mjs +2 -2
- package/cargo-contract.mjs +7 -2
- package/cargo-guard.test.mjs +1 -0
- package/cli/right-release.mjs +2 -2
- package/github-release.mjs +25 -10
- package/heavy-command.mjs +27 -11
- package/heavy-command.test.mjs +49 -0
- package/package.json +4 -3
- package/release-cli-contract.test.mjs +17 -6
- package/release-state.mjs +22 -3
- package/release-state.test.mjs +45 -6
- package/release.mjs +15 -15
- package/release.test.mjs +24 -2
- package/right-suite-contract.test.mjs +23 -5
- package/rightkit-versions.json +4 -2
- package/standalone-clone-evidence.json +32 -0
- package/upload-release.mjs +36 -170
package/build-release.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
closeSync,
|
|
6
6
|
copyFileSync,
|
|
7
7
|
existsSync,
|
|
8
|
+
mkdtempSync,
|
|
8
9
|
mkdirSync,
|
|
9
10
|
openSync,
|
|
10
11
|
readFileSync,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
unlinkSync,
|
|
18
19
|
writeFileSync,
|
|
19
20
|
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
20
22
|
import path from "node:path";
|
|
21
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
22
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -163,6 +165,9 @@ try {
|
|
|
163
165
|
if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
|
|
164
166
|
const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
|
|
165
167
|
const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
|
|
168
|
+
const managedCargoTarget = process.env.RIGHTKIT_BUILD_BROKER_SOCKET
|
|
169
|
+
? resolveManagedCargoTarget(cargoToml)
|
|
170
|
+
: null;
|
|
166
171
|
const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
|
|
167
172
|
const cacheKey = hashFileText(JSON.stringify({ cache: cacheIdentity.fingerprint, signingIdentity })).slice(0, 16);
|
|
168
173
|
const cacheMode = process.env.RIGHT_RELEASE_CACHE_MODE ?? (platform === "mac" || platform === "win" ? "shared" : "legacy");
|
|
@@ -197,6 +202,11 @@ try {
|
|
|
197
202
|
RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
|
|
198
203
|
RIGHT_RELEASE_APP_ROOT: layout.appRoot,
|
|
199
204
|
};
|
|
205
|
+
if (managedCargoTarget) {
|
|
206
|
+
for (const name of ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "RUSTFLAGS"]) {
|
|
207
|
+
delete env[name];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
200
210
|
if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
|
|
201
211
|
if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
|
|
202
212
|
if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
|
|
@@ -208,7 +218,7 @@ try {
|
|
|
208
218
|
throwIfInterrupted();
|
|
209
219
|
const releaseId = `${config.app}-${config.version}-${shortCommit}`;
|
|
210
220
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
211
|
-
const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
|
|
221
|
+
const buildRoot = managedCargoTarget ? null : path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
|
|
212
222
|
const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
|
|
213
223
|
const lockPayload = {
|
|
214
224
|
schema: 1,
|
|
@@ -229,8 +239,8 @@ try {
|
|
|
229
239
|
// instead of hard-stopping the build.
|
|
230
240
|
const targetBridge = createTargetBridge({
|
|
231
241
|
link: targetLink,
|
|
232
|
-
target: env.CARGO_TARGET_DIR,
|
|
233
|
-
ownedRoot: path.dirname(env.CARGO_TARGET_DIR),
|
|
242
|
+
target: managedCargoTarget ?? env.CARGO_TARGET_DIR,
|
|
243
|
+
ownedRoot: path.dirname(managedCargoTarget ?? env.CARGO_TARGET_DIR),
|
|
234
244
|
});
|
|
235
245
|
|
|
236
246
|
const result = await targetBridge.run(async () => runBuildStateMachine({
|
|
@@ -256,15 +266,15 @@ try {
|
|
|
256
266
|
assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
|
|
257
267
|
for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
|
|
258
268
|
for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
|
|
259
|
-
mkdirSync(buildRoot, { recursive: true });
|
|
269
|
+
if (buildRoot) mkdirSync(buildRoot, { recursive: true });
|
|
260
270
|
mkdirSync(stateRoot, { recursive: true });
|
|
261
|
-
writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
|
|
271
|
+
if (buildRoot) writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
|
|
262
272
|
writeJson(path.join(stateRoot, "release-inputs.lock.json"), lockPayload);
|
|
263
273
|
checkpoint(stateRoot, "preflight_complete");
|
|
264
274
|
},
|
|
265
275
|
prepare: async () => {
|
|
266
276
|
throwIfInterrupted();
|
|
267
|
-
const cacheTarget = env.CARGO_TARGET_DIR;
|
|
277
|
+
const cacheTarget = managedCargoTarget ?? env.CARGO_TARGET_DIR;
|
|
268
278
|
mkdirSync(cacheTarget, { recursive: true });
|
|
269
279
|
if (cacheMode === "shared") targetBridge.ensure();
|
|
270
280
|
else if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
|
|
@@ -280,7 +290,7 @@ try {
|
|
|
280
290
|
[WORKER, "--config", configPath, "--platform", platform, "--no-upload"],
|
|
281
291
|
appRoot,
|
|
282
292
|
env,
|
|
283
|
-
[env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
293
|
+
[managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
284
294
|
);
|
|
285
295
|
checkpoint(stateRoot, "build_complete");
|
|
286
296
|
checkpoint(stateRoot, "signed");
|
|
@@ -391,15 +401,32 @@ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platfor
|
|
|
391
401
|
}
|
|
392
402
|
|
|
393
403
|
function collectToolVersions(packageManager) {
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
404
|
+
const probeDir = mkdtempSync(path.join(tmpdir(), "right-release-tool-probe-"));
|
|
405
|
+
try {
|
|
406
|
+
const rustcVerbose = commandOutputAt("rustc", ["-vV"], probeDir);
|
|
407
|
+
return {
|
|
408
|
+
node: process.version,
|
|
409
|
+
packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
|
|
410
|
+
cargo: commandOutputAt("cargo", ["--version"], probeDir),
|
|
411
|
+
rustc: rustcVerbose,
|
|
412
|
+
rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
|
|
413
|
+
sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
|
|
414
|
+
};
|
|
415
|
+
} finally {
|
|
416
|
+
rmSync(probeDir, { recursive: true, force: true });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function resolveManagedCargoTarget(manifestPath) {
|
|
421
|
+
if (!manifestPath) fail("managed release requires a Cargo.toml build input");
|
|
422
|
+
const output = commandOutputAt(
|
|
423
|
+
"cargo",
|
|
424
|
+
["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
|
|
425
|
+
path.dirname(manifestPath),
|
|
426
|
+
);
|
|
427
|
+
const target = JSON.parse(output).target_directory;
|
|
428
|
+
if (!target || !path.isAbsolute(target)) fail("managed Cargo metadata returned no absolute target directory");
|
|
429
|
+
return target;
|
|
403
430
|
}
|
|
404
431
|
|
|
405
432
|
async function runProgress(cmd, runArgs, cwd, env, watchDir) {
|
package/build-release.test.mjs
CHANGED
|
@@ -66,11 +66,11 @@ test("dirty release config is rejected before the config module is imported", ()
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("shared Cache V2 target bridge is owned by a finally-cleaned lifecycle", () => {
|
|
69
|
-
assert.match(source, /createTargetBridge\(\{\s*link: targetLink,\s*target: env\.CARGO_TARGET_DIR,/);
|
|
69
|
+
assert.match(source, /createTargetBridge\(\{\s*link: targetLink,\s*target: managedCargoTarget \?\? env\.CARGO_TARGET_DIR,/);
|
|
70
70
|
// ownedRoot must stay wired: without it the bridge refuses every link left by
|
|
71
71
|
// an earlier fingerprint, so any Cargo.lock or version bump hard-stops the
|
|
72
72
|
// next build until someone deletes the link by hand.
|
|
73
|
-
assert.match(source, /ownedRoot: path\.dirname\(env\.CARGO_TARGET_DIR\)/);
|
|
73
|
+
assert.match(source, /ownedRoot: path\.dirname\(managedCargoTarget \?\? env\.CARGO_TARGET_DIR\)/);
|
|
74
74
|
assert.match(source, /targetBridge\.run\(.*runBuildStateMachine/s);
|
|
75
75
|
assert.match(source, /if \(cacheMode === "shared"\) targetBridge\.ensure\(\)/);
|
|
76
76
|
});
|
package/cargo-contract.mjs
CHANGED
|
@@ -13,11 +13,16 @@ const CACHE_V2_ENVIRONMENT = ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER",
|
|
|
13
13
|
export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
|
|
14
14
|
const metadataEnv = { ...env };
|
|
15
15
|
for (const name of CACHE_V2_ENVIRONMENT) delete metadataEnv[name];
|
|
16
|
+
if (metadataEnv.RIGHTKIT_BUILD_BROKER_SOCKET) return metadataEnv;
|
|
16
17
|
return { ...metadataEnv, CARGO_HOME: cargoHome };
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function cargoExecutable(platform = process.platform) {
|
|
20
|
-
return platform === "win32" ? "
|
|
21
|
+
return platform === "win32" ? "rustup" : "cargo";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cargoArguments(args, platform = process.platform) {
|
|
25
|
+
return platform === "win32" ? ["run", "stable", "cargo", ...args] : args;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
|
|
@@ -77,7 +82,7 @@ export function assertNoRightKitCargoOverrides(manifestPath, repoRoot, label) {
|
|
|
77
82
|
function readCargoManifestDependencies(manifestPath, cargoHome, label) {
|
|
78
83
|
const result = spawnSync(
|
|
79
84
|
cargoExecutable(),
|
|
80
|
-
["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
|
|
85
|
+
cargoArguments(["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath]),
|
|
81
86
|
{
|
|
82
87
|
cwd: path.dirname(manifestPath),
|
|
83
88
|
encoding: "utf8",
|
package/cargo-guard.test.mjs
CHANGED
|
@@ -84,6 +84,7 @@ test("Cargo guard routes heavy and light commands without recursion", async () =
|
|
|
84
84
|
env: { TEST: "1", ...cacheEnv() },
|
|
85
85
|
policyOptions: { exists: () => false },
|
|
86
86
|
resolveCargo: () => "/real/cargo",
|
|
87
|
+
resolveRustc: () => "/real/rustc",
|
|
87
88
|
runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
|
|
88
89
|
runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
|
|
89
90
|
};
|
package/cli/right-release.mjs
CHANGED
|
@@ -149,8 +149,8 @@ Commands:
|
|
|
149
149
|
generate-dmg-background <options> Generate the branded multi-resolution DMG background
|
|
150
150
|
mirror-root-artifact --file <path> --package-root <dir>
|
|
151
151
|
Persist a worktree artifact in the primary repo root
|
|
152
|
-
github --release <id> --platform mac|win --repo owner/repo [--dry-run]
|
|
153
|
-
Attach
|
|
152
|
+
github --release <id> --platform mac|win [--repo owner/repo] [--dry-run]
|
|
153
|
+
Attach sealed signed artifacts to a verified GitHub Release
|
|
154
154
|
|
|
155
155
|
Direct flags are treated as: right-release build <flags>.
|
|
156
156
|
Build is tier-neutral. Upload requires an explicit tier. Unsigned/local smoke builds stay app-local.`);
|
package/github-release.mjs
CHANGED
|
@@ -24,24 +24,32 @@ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stat
|
|
|
24
24
|
const manifestAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-release-manifest.json`);
|
|
25
25
|
const checksumsAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-SHA256SUMS.txt`);
|
|
26
26
|
copyFileSync(sealed.manifestPath, manifestAsset);
|
|
27
|
-
writeFileSync(checksumsAsset, `${
|
|
27
|
+
writeFileSync(checksumsAsset, `${sealed.manifest.files.map((file) => `${file.sha256} ${file.name}`).join("\n")}\n`);
|
|
28
28
|
const installer = path.join(sealed.sealedDir, installers[0].name);
|
|
29
|
+
const assets = [...sealed.manifest.files.map((file) => path.join(sealed.sealedDir, file.name)), manifestAsset, checksumsAsset];
|
|
29
30
|
return {
|
|
30
31
|
sealed,
|
|
31
32
|
installer,
|
|
32
|
-
assets
|
|
33
|
+
assets,
|
|
33
34
|
tag: `v${sealed.manifest.version}`,
|
|
34
35
|
title: `${sealed.manifest.app === "cutright" ? "CutRight Studio" : sealed.manifest.app} ${sealed.manifest.version}`,
|
|
35
36
|
notes: [
|
|
36
37
|
`Signed ${platform === "mac" ? "& notarized universal macOS" : "Windows"} installer.`,
|
|
37
38
|
"",
|
|
38
39
|
`Build commit: \`${sealed.manifest.commit}\``,
|
|
39
|
-
`
|
|
40
|
+
`Artifacts: ${sealed.manifest.files.map((file) => `\`${file.name}\``).join(", ")}`,
|
|
40
41
|
].join("\n"),
|
|
41
42
|
notesFile: path.join(releaseState, "release-notes.md"),
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
export function repositoryFromRemote(repoRoot) {
|
|
47
|
+
const remote = runCommand("git", ["remote", "get-url", "origin"], { cwd: repoRoot });
|
|
48
|
+
const match = remote.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
49
|
+
if (!match || !REPO_RE.test(match[1])) throw new Error(`origin is not a GitHub repository: ${remote.trim()}`);
|
|
50
|
+
return match[1];
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoRoot, platform, repo }) {
|
|
46
54
|
if (platform !== "mac" && platform !== "win") throw new Error("platform must be mac or win");
|
|
47
55
|
if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
|
|
@@ -72,12 +80,16 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
|
|
|
72
80
|
|
|
73
81
|
export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand } = {}) {
|
|
74
82
|
const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
|
|
75
|
-
if (visibility.visibility !== "PUBLIC"
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
if (visibility.visibility !== "PUBLIC" && !(plan.kind !== "addon" && visibility.visibility === "PRIVATE")) {
|
|
84
|
+
throw new Error(`GitHub releases require a public or private repository: ${repo}`);
|
|
85
|
+
}
|
|
86
|
+
if (!dryRun) {
|
|
87
|
+
if (plan.kind === "addon") verifyAddonTrust(plan);
|
|
88
|
+
else verifyPlatformTrust(plan);
|
|
89
|
+
}
|
|
78
90
|
const existing = run("gh", ["release", "view", plan.tag, "--repo", repo, "--json", "tagName"], { allowFailure: true });
|
|
79
91
|
if (dryRun) return { status: existing.ok ? plan.kind === "addon" ? "would-verify" : "would-update" : "would-create", tag: plan.tag, assets: plan.assets };
|
|
80
|
-
if (
|
|
92
|
+
if (existing.ok) {
|
|
81
93
|
const missing = plan.assets.filter((asset) => inspectRemoteAsset(plan.tag, repo, asset, run) === "missing");
|
|
82
94
|
if (missing.length === 0) return { status: "already-verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
|
|
83
95
|
run("gh", ["release", "upload", plan.tag, ...missing, "--repo", repo]);
|
|
@@ -90,7 +102,7 @@ export function publishGitHubRelease(plan, { repo, dryRun = false, run = runComm
|
|
|
90
102
|
run("gh", ["release", "create", plan.tag, "--repo", repo, "--title", plan.title, "--notes-file", notesFile]);
|
|
91
103
|
rmSync(notesFile, { force: true });
|
|
92
104
|
}
|
|
93
|
-
run("gh", ["release", "upload", plan.tag, ...plan.assets, "--repo", repo
|
|
105
|
+
run("gh", ["release", "upload", plan.tag, ...plan.assets, "--repo", repo]);
|
|
94
106
|
for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run);
|
|
95
107
|
return { status: "verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
|
|
96
108
|
}
|
|
@@ -107,7 +119,10 @@ function verifyAddonTrust(plan) {
|
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
function verifyPlatformTrust(plan) {
|
|
110
|
-
if (plan.sealed.manifest.platform
|
|
122
|
+
if (plan.sealed.manifest.platform === "win") {
|
|
123
|
+
runCommand(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs"), "--verify-only", plan.installer]);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
111
126
|
runCommand("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", plan.installer]);
|
|
112
127
|
runCommand("xcrun", ["stapler", "validate", plan.installer]);
|
|
113
128
|
}
|
|
@@ -179,7 +194,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
179
194
|
if (config.distribution?.provider !== "github-releases") throw new Error("add-on config must select github-releases distribution");
|
|
180
195
|
plan = prepareGitHubAddonRelease({ repoRoot, config, configRoot: path.dirname(configPath), ...options });
|
|
181
196
|
} else {
|
|
182
|
-
|
|
197
|
+
options.repo ||= repositoryFromRemote(repoRoot);
|
|
183
198
|
plan = prepareGitHubRelease({ repoRoot, ...options });
|
|
184
199
|
}
|
|
185
200
|
const result = publishGitHubRelease(plan, options);
|
package/heavy-command.mjs
CHANGED
|
@@ -133,6 +133,30 @@ function writeTrackedChild(lockDir, owner, child) {
|
|
|
133
133
|
renameSync(temporary, file);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
function matchingProcessIdentity(pid, expectedStartedAtMs, { alive = processAlive, startedAt = processStartedAt, assumeCurrentWhenStartUnavailable = false } = {}) {
|
|
137
|
+
if (!alive(pid)) return false;
|
|
138
|
+
if (!Number.isFinite(Number(expectedStartedAtMs))) return assumeCurrentWhenStartUnavailable;
|
|
139
|
+
const observedStartedAtMs = startedAt(pid);
|
|
140
|
+
if (!Number.isFinite(observedStartedAtMs)) return assumeCurrentWhenStartUnavailable;
|
|
141
|
+
return Math.abs(observedStartedAtMs - Number(expectedStartedAtMs)) <= 2000;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function sweepTrackedHeavyWorkOrphan({ root = heavyWorkRoot(), alive = processAlive, startedAt = processStartedAt, terminate = terminateProcessTree, log = (message) => console.error(`[heavy-work] ${message}`) } = {}) {
|
|
145
|
+
const lockDir = path.join(root, "slot");
|
|
146
|
+
const owner = readOwner(lockDir);
|
|
147
|
+
if (!owner) return false;
|
|
148
|
+
if (matchingProcessIdentity(owner.pid, owner.ownerStartedAtMs, { alive, startedAt, assumeCurrentWhenStartUnavailable: true })) return false;
|
|
149
|
+
if (owner.childPid && alive(owner.childPid)) {
|
|
150
|
+
if (!matchingProcessIdentity(owner.childPid, owner.childStartedAtMs, { alive, startedAt })) {
|
|
151
|
+
throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
|
|
152
|
+
}
|
|
153
|
+
log(`reaping owned process tree pid ${owner.childPid} after stale owner pid ${owner.pid}`);
|
|
154
|
+
terminate(owner.childPid);
|
|
155
|
+
}
|
|
156
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
export function processStartedAt(pid, { platform = process.platform, run = spawnSync } = {}) {
|
|
137
161
|
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
|
|
138
162
|
const result = platform === "win32" || platform === "win"
|
|
@@ -206,23 +230,15 @@ export function acquireHeavyWorkSlot({
|
|
|
206
230
|
try {
|
|
207
231
|
mkdirSync(lockDir);
|
|
208
232
|
const fd = openSync(path.join(lockDir, "owner.json"), "wx");
|
|
209
|
-
writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
|
|
233
|
+
writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), ownerStartedAtMs: startedAt(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
|
|
210
234
|
closeSync(fd);
|
|
211
235
|
break;
|
|
212
236
|
} catch (error) {
|
|
213
237
|
if (error.code !== "EEXIST") throw error;
|
|
214
238
|
const owner = readOwner(lockDir);
|
|
215
239
|
const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
|
|
216
|
-
if (owner ?
|
|
217
|
-
if (owner
|
|
218
|
-
const observedStart = startedAt(owner.childPid);
|
|
219
|
-
if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(owner.childStartedAtMs)) > 2000) {
|
|
220
|
-
throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
|
|
221
|
-
}
|
|
222
|
-
log(`reaping owned process tree pid ${owner.childPid} after owner pid ${owner.pid} exited`);
|
|
223
|
-
terminate(owner.childPid);
|
|
224
|
-
}
|
|
225
|
-
rmSync(lockDir, { recursive: true, force: true });
|
|
240
|
+
if (owner ? sweepTrackedHeavyWorkOrphan({ root, alive, startedAt, terminate, log }) : incompleteAge > 10_000) {
|
|
241
|
+
if (!owner) rmSync(lockDir, { recursive: true, force: true });
|
|
226
242
|
continue;
|
|
227
243
|
}
|
|
228
244
|
if (Date.now() - started >= waitMs) throw new Error(`heavy-work slot timed out after ${waitMs}ms; holder pid ${owner?.pid ?? "starting"}`);
|
package/heavy-command.test.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
parseWindowsResourceSnapshot,
|
|
16
16
|
resourceBlockers,
|
|
17
17
|
runHeavyCommand,
|
|
18
|
+
sweepTrackedHeavyWorkOrphan,
|
|
18
19
|
systemResourceSnapshot,
|
|
19
20
|
terminateProcessTree,
|
|
20
21
|
watchOwnedProcessTree,
|
|
@@ -147,6 +148,54 @@ test("dead slot owner refuses to kill a reused child PID", () => {
|
|
|
147
148
|
first.release();
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
test("preflight sweep reaps only a tracked child after an owner PID is reused", () => {
|
|
152
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-preflight-"));
|
|
153
|
+
const lockDir = path.join(root, "slot");
|
|
154
|
+
mkdirSync(lockDir);
|
|
155
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, ownerStartedAtMs: 100, token: "stale" }));
|
|
156
|
+
writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 200 }));
|
|
157
|
+
const killed = [];
|
|
158
|
+
assert.equal(sweepTrackedHeavyWorkOrphan({
|
|
159
|
+
root,
|
|
160
|
+
alive: (pid) => pid === 101 || pid === 202,
|
|
161
|
+
startedAt: (pid) => pid === 101 ? 5_000 : pid === 202 ? 200 : null,
|
|
162
|
+
terminate: (pid) => killed.push(pid),
|
|
163
|
+
log: () => {},
|
|
164
|
+
}), true);
|
|
165
|
+
assert.deepEqual(killed, [202]);
|
|
166
|
+
assert.equal(existsSync(lockDir), false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("preflight sweep refuses a reused tracked child PID instead of killing broadly", () => {
|
|
170
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-preflight-reused-"));
|
|
171
|
+
const lockDir = path.join(root, "slot");
|
|
172
|
+
mkdirSync(lockDir);
|
|
173
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, ownerStartedAtMs: 100, token: "stale" }));
|
|
174
|
+
writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 200 }));
|
|
175
|
+
assert.throws(() => sweepTrackedHeavyWorkOrphan({
|
|
176
|
+
root,
|
|
177
|
+
alive: (pid) => pid === 101 || pid === 202,
|
|
178
|
+
startedAt: (pid) => pid === 101 ? 5_000 : pid === 202 ? 4_000 : null,
|
|
179
|
+
terminate: () => assert.fail("reused child PID must not be terminated"),
|
|
180
|
+
log: () => {},
|
|
181
|
+
}), /identity changed/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("preflight sweep refuses a tracked child without a verifiable start identity", () => {
|
|
185
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-preflight-unverified-"));
|
|
186
|
+
const lockDir = path.join(root, "slot");
|
|
187
|
+
mkdirSync(lockDir);
|
|
188
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, ownerStartedAtMs: 100, token: "stale" }));
|
|
189
|
+
writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 200 }));
|
|
190
|
+
assert.throws(() => sweepTrackedHeavyWorkOrphan({
|
|
191
|
+
root,
|
|
192
|
+
alive: (pid) => pid === 101 || pid === 202,
|
|
193
|
+
startedAt: (pid) => pid === 101 ? 5_000 : null,
|
|
194
|
+
terminate: () => assert.fail("unverified child PID must not be terminated"),
|
|
195
|
+
log: () => {},
|
|
196
|
+
}), /identity changed/);
|
|
197
|
+
});
|
|
198
|
+
|
|
150
199
|
test("detached watcher reaps an owned child as soon as its owner disappears", async () => {
|
|
151
200
|
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-watch-"));
|
|
152
201
|
const lockDir = path.join(root, "slot");
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts,
|
|
3
|
+
"version": "0.2.62",
|
|
4
|
+
"description": "Portable Right Suite release CLI/SDK: native-host signed installers, updater artifacts, hardening, immutable GitHub Release upload, and add-on adoption.",
|
|
5
|
+
"license": "MIT OR Apache-2.0",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
7
8
|
"right-release": "cli/right-release.mjs"
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
},
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/
|
|
24
|
+
"url": "git+https://github.com/bogusyogi/claude.git",
|
|
24
25
|
"directory": "tools/rightkit/packages/release"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
@@ -61,12 +61,14 @@ test("upload requires an explicit patch or update tier before reading a release"
|
|
|
61
61
|
assert.match(result.stderr, /tier is required.*patch\|update/i);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
test("
|
|
64
|
+
test("GitHub upload delegates platform trust verification to the shared release lane", () => {
|
|
65
65
|
const uploadSource = readFileSync(upload, "utf8");
|
|
66
|
+
const githubSource = readFileSync(path.join(root, "github-release.mjs"), "utf8");
|
|
66
67
|
const signingSource = readFileSync(signWindows, "utf8");
|
|
67
|
-
assert.match(uploadSource, /
|
|
68
|
-
assert.match(
|
|
69
|
-
assert.
|
|
68
|
+
assert.match(uploadSource, /publishGitHubRelease/);
|
|
69
|
+
assert.match(githubSource, /sign-windows\.mjs/);
|
|
70
|
+
assert.match(githubSource, /--verify-only/);
|
|
71
|
+
assert.doesNotMatch(uploadSource, /CLOUDFLARE_API_TOKEN|wrangler|R2/i);
|
|
70
72
|
assert.match(signingSource, /verifyOnly/);
|
|
71
73
|
assert.match(signingSource, /\["verify", "\/pa", "\/v", file\]/);
|
|
72
74
|
});
|
|
@@ -101,8 +103,17 @@ test("Windows signer verification accepts CRLF subjects and retains identity che
|
|
|
101
103
|
);
|
|
102
104
|
});
|
|
103
105
|
|
|
104
|
-
test("upload uses
|
|
106
|
+
test("upload uses GitHub CLI and never invokes the R2 runner", () => {
|
|
105
107
|
const source = readFileSync(upload, "utf8");
|
|
106
|
-
|
|
108
|
+
const githubSource = readFileSync(path.join(root, "github-release.mjs"), "utf8");
|
|
109
|
+
assert.match(githubSource, /run\("gh", \["release", "upload"/);
|
|
110
|
+
assert.doesNotMatch(source, /wrangler|CLOUDFLARE_API_TOKEN|RIGHTAPPS_API_URL/i);
|
|
107
111
|
assert.doesNotMatch(source, /\bnpx\b/);
|
|
108
112
|
});
|
|
113
|
+
|
|
114
|
+
test("upload accepts only GitHub Releases as configured product distribution", () => {
|
|
115
|
+
const source = readFileSync(upload, "utf8");
|
|
116
|
+
assert.match(source, /distribution\.provider/);
|
|
117
|
+
assert.match(source, /github-releases/);
|
|
118
|
+
assert.match(source, /releaseConfig\?\.distribution\?\.repository/);
|
|
119
|
+
});
|
package/release-state.mjs
CHANGED
|
@@ -46,14 +46,16 @@ export function commandOutputPortable(cmd, args, { cwd, env = process.env } = {}
|
|
|
46
46
|
return result.stdout.trim();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export function watchProgress(paths, onProgress) {
|
|
49
|
+
export function watchProgress(paths, onProgress, { maxFallbackWatchers = 128, watchFactory = watch, readDirectory = readdirSync } = {}) {
|
|
50
|
+
if (!Number.isInteger(maxFallbackWatchers) || maxFallbackWatchers < 1) throw new TypeError("maxFallbackWatchers must be a positive integer");
|
|
50
51
|
const watchers = [];
|
|
52
|
+
const fallback = { remaining: maxFallbackWatchers };
|
|
51
53
|
for (const candidate of paths) {
|
|
52
54
|
if (!candidate || !existsSync(candidate)) continue;
|
|
53
55
|
try {
|
|
54
|
-
watchers.push(
|
|
56
|
+
watchers.push(watchFactory(candidate, { recursive: true }, onProgress));
|
|
55
57
|
} catch {
|
|
56
|
-
|
|
58
|
+
watchDirectoryTree(candidate, onProgress, watchers, fallback, { watchFactory, readDirectory });
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
return () => {
|
|
@@ -61,6 +63,23 @@ export function watchProgress(paths, onProgress) {
|
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
function watchDirectoryTree(root, onProgress, watchers, fallback, { watchFactory = watch, readDirectory = readdirSync } = {}) {
|
|
67
|
+
const pending = [root];
|
|
68
|
+
const visited = new Set();
|
|
69
|
+
while (pending.length) {
|
|
70
|
+
const directory = pending.pop();
|
|
71
|
+
if (visited.has(directory)) continue;
|
|
72
|
+
visited.add(directory);
|
|
73
|
+
if (fallback.remaining <= 0) return;
|
|
74
|
+
try { watchers.push(watchFactory(directory, onProgress)); fallback.remaining -= 1; } catch { /* output still counts as progress */ }
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = readDirectory(directory, { withFileTypes: true }); } catch { continue; }
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.isDirectory()) pending.push(path.join(directory, entry.name));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
export function releaseEnvironment({ root, cacheRoot, platform, architecture, app, cacheKey, kind = "release", appRoot, mode = "legacy", env = process.env }) {
|
|
65
84
|
if (kind !== "release" && kind !== "test") throw new Error(`invalid target kind: ${kind}`);
|
|
66
85
|
if (mode !== "legacy" && mode !== "shared") throw new Error(`invalid cache mode: ${mode}`);
|
package/release-state.test.mjs
CHANGED
|
@@ -23,20 +23,59 @@ test("progress watcher observes writes in nested Cargo target directories", asyn
|
|
|
23
23
|
const root = mkdtempSync(path.join(os.tmpdir(), "right-release-watch-"));
|
|
24
24
|
const nested = path.join(root, "release", "build", "openssl");
|
|
25
25
|
mkdirSync(nested, { recursive: true });
|
|
26
|
+
const readyFile = path.join(root, "watch-ready");
|
|
27
|
+
const nestedFile = path.join(nested, "object.lib");
|
|
28
|
+
let resolveReady;
|
|
26
29
|
let resolveProgress;
|
|
30
|
+
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
27
31
|
const progress = new Promise((resolve) => { resolveProgress = resolve; });
|
|
28
|
-
const close = watchProgress([root],
|
|
32
|
+
const close = watchProgress([root], (_eventType, filename) => {
|
|
33
|
+
const eventName = String(filename ?? "");
|
|
34
|
+
if (eventName === path.basename(readyFile)) resolveReady();
|
|
35
|
+
if (eventName.endsWith(path.basename(nestedFile))) resolveProgress();
|
|
36
|
+
});
|
|
29
37
|
try {
|
|
30
|
-
await
|
|
31
|
-
await
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
39
|
+
await writeFile(readyFile, "ready");
|
|
40
|
+
await waitForEvent(ready, "watcher readiness event missing");
|
|
41
|
+
await writeFile(nestedFile, "progress");
|
|
42
|
+
await waitForEvent(progress, "nested progress event missing");
|
|
43
|
+
} finally {
|
|
44
|
+
close();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("fallback progress watchers cap open handles when recursive watching is unavailable", () => {
|
|
49
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "right-release-watch-cap-"));
|
|
50
|
+
for (let index = 0; index < 10; index += 1) mkdirSync(path.join(root, `dir-${index}`));
|
|
51
|
+
const opened = [];
|
|
52
|
+
const close = watchProgress([root], () => {}, {
|
|
53
|
+
maxFallbackWatchers: 3,
|
|
54
|
+
watchFactory: (directory, options) => {
|
|
55
|
+
if (options?.recursive) throw new Error("recursive watch unsupported");
|
|
56
|
+
opened.push(directory);
|
|
57
|
+
return { close: () => {} };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
assert.equal(opened.length, 3);
|
|
35
62
|
} finally {
|
|
36
63
|
close();
|
|
37
64
|
}
|
|
38
65
|
});
|
|
39
66
|
|
|
67
|
+
async function waitForEvent(event, message) {
|
|
68
|
+
let timer;
|
|
69
|
+
try {
|
|
70
|
+
await Promise.race([
|
|
71
|
+
event,
|
|
72
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), 5_000); }),
|
|
73
|
+
]);
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
test("portable command capture resolves Windows command shims", { skip: process.platform !== "win32" }, () => {
|
|
41
80
|
assert.equal(commandOutputPortable("pnpm", ["--version"]), expectedPnpmVersion);
|
|
42
81
|
});
|
package/release.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInpu
|
|
|
12
12
|
import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
13
13
|
import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
|
|
14
14
|
import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
|
|
15
|
+
import { terminateProcessTree } from "./heavy-command.mjs";
|
|
15
16
|
|
|
16
17
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -29,6 +30,7 @@ const RIGHTKIT_ALLOWED = buildAllowedVersions(
|
|
|
29
30
|
const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(RIGHTKIT_VERSIONS.cargo, RIGHTKIT_VERSIONS.stagedCargo);
|
|
30
31
|
const FORBIDDEN_RIGHTKIT_SPEC = /^(?:git|file|link|workspace):|github|github\.com/i;
|
|
31
32
|
const WINDOWS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-v1";
|
|
33
|
+
const DEFAULT_NOTARIZATION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
32
34
|
|
|
33
35
|
const args = process.argv.slice(2);
|
|
34
36
|
const opts = {
|
|
@@ -344,7 +346,18 @@ async function runPackageScript(pm, script, cwd) {
|
|
|
344
346
|
|
|
345
347
|
async function runCommand(command, root) {
|
|
346
348
|
const cwd = path.resolve(root, command.cwd ?? ".");
|
|
347
|
-
await run(command.cmd, command.args ?? [], cwd, await commandEnv(command, root),
|
|
349
|
+
await run(command.cmd, command.args ?? [], cwd, await commandEnv(command, root), {
|
|
350
|
+
...command,
|
|
351
|
+
timeoutMs: commandTimeoutMs(command),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function commandTimeoutMs(command) {
|
|
356
|
+
if (Object.hasOwn(command, "timeoutMs")) return command.timeoutMs;
|
|
357
|
+
const tokens = [command.cmd, ...(command.args ?? [])].map((value) => String(value));
|
|
358
|
+
return command.notarize === true || command.notarization === true || tokens.some((value) => /notari[sz]/i.test(value))
|
|
359
|
+
? DEFAULT_NOTARIZATION_TIMEOUT_MS
|
|
360
|
+
: undefined;
|
|
348
361
|
}
|
|
349
362
|
|
|
350
363
|
async function validateRightKitPackageContract(root, appName) {
|
|
@@ -624,18 +637,5 @@ function processCommandLine(pid) {
|
|
|
624
637
|
}
|
|
625
638
|
|
|
626
639
|
function killProcessTree(pid) {
|
|
627
|
-
|
|
628
|
-
if (process.platform === "win32") {
|
|
629
|
-
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
const children = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
|
|
633
|
-
for (const child of (children.stdout ?? "").split(/\s+/).filter(Boolean)) {
|
|
634
|
-
killProcessTree(Number(child));
|
|
635
|
-
}
|
|
636
|
-
try {
|
|
637
|
-
process.kill(pid, "SIGKILL");
|
|
638
|
-
} catch {
|
|
639
|
-
// already gone
|
|
640
|
-
}
|
|
640
|
+
terminateProcessTree(pid);
|
|
641
641
|
}
|
package/release.test.mjs
CHANGED
|
@@ -16,7 +16,7 @@ function git(cwd, ...args) {
|
|
|
16
16
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function fixture({ signed = true, publish = false, signingContract = "windows-raw-exe-authenticode-before-nsis-v1", prePackageFiles = ["raw.exe"], packageJson, cargoManifest, cargoConfig, repoCargoConfig, siblingCargoManifest, buildInputs = defaultBuildInputs, platform = "win" } = {}) {
|
|
19
|
+
function fixture({ signed = true, publish = false, signingContract = "windows-raw-exe-authenticode-before-nsis-v1", prePackageFiles = ["raw.exe"], packageJson, packageCommand, cargoManifest, cargoConfig, repoCargoConfig, siblingCargoManifest, buildInputs = defaultBuildInputs, platform = "win" } = {}) {
|
|
20
20
|
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "right-release-test-"));
|
|
21
21
|
const dir = repoCargoConfig ? path.join(fixtureRoot, "apps", "fixture") : fixtureRoot;
|
|
22
22
|
mkdirSync(dir, { recursive: true });
|
|
@@ -49,7 +49,7 @@ function fixture({ signed = true, publish = false, signingContract = "windows-ra
|
|
|
49
49
|
[platform]: {
|
|
50
50
|
...(buildInputs ? { buildInputs } : {}),
|
|
51
51
|
signed,
|
|
52
|
-
package: { cmd: "node", args: ["-e", "process.exit(0)"] },
|
|
52
|
+
package: packageCommand ?? { cmd: "node", args: ["-e", "process.exit(0)"] },
|
|
53
53
|
...(publish ? { publish: { cmd: "node", args: ["publish-update.mjs"] } } : {}),
|
|
54
54
|
artifacts: [],
|
|
55
55
|
...(platform === "win" ? { signingContract, prePackage: { cmd: "node", args: ["-e", "process.exit(0)"] }, sign: { prePackageFiles, files: ["fixture.exe"] } } : {}),
|
|
@@ -159,6 +159,28 @@ test("accepts a tier-neutral internal build", () => {
|
|
|
159
159
|
assert.doesNotMatch(result.stdout, /RIGHT_RELEASE_TIER=/);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
test("notarization commands default to a bounded 30-minute timeout without changing explicit overrides", () => {
|
|
163
|
+
const defaultResult = runRaw(fixture({
|
|
164
|
+
platform: "mac",
|
|
165
|
+
packageCommand: { cmd: "node", args: ["-e", "process.exit(0)", "--notarize"] },
|
|
166
|
+
}), "--platform", "mac", "--dry-run");
|
|
167
|
+
assert.equal(defaultResult.status, 0, defaultResult.stderr);
|
|
168
|
+
assert.match(defaultResult.stdout, /timeout=1800000ms/);
|
|
169
|
+
|
|
170
|
+
const explicitResult = runRaw(fixture({
|
|
171
|
+
platform: "mac",
|
|
172
|
+
packageCommand: { cmd: "node", args: ["-e", "process.exit(0)", "--notarize"], timeoutMs: 1234 },
|
|
173
|
+
}), "--platform", "mac", "--dry-run");
|
|
174
|
+
assert.equal(explicitResult.status, 0, explicitResult.stderr);
|
|
175
|
+
assert.match(explicitResult.stdout, /timeout=1234ms/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("release worker reuses owned process-tree termination for Windows and remote command descendants", () => {
|
|
179
|
+
const source = readFileSync(release, "utf8");
|
|
180
|
+
assert.match(source, /import \{ terminateProcessTree \} from "\.\/heavy-command\.mjs"/);
|
|
181
|
+
assert.match(source, /function killProcessTree\(pid\) \{\s*terminateProcessTree\(pid\);\s*\}/s);
|
|
182
|
+
});
|
|
183
|
+
|
|
162
184
|
test("accepts patch and exposes it to the signed package command", () => {
|
|
163
185
|
const result = run(fixture(), "--tier=patch");
|
|
164
186
|
assert.equal(result.status, 0, result.stderr);
|
|
@@ -8,7 +8,9 @@ import test, { after } from "node:test";
|
|
|
8
8
|
import {
|
|
9
9
|
assertNoRightKitCargoOverrides,
|
|
10
10
|
assertPublishedRightKitCargoDependencies,
|
|
11
|
+
cargoArguments,
|
|
11
12
|
cargoExecutable,
|
|
13
|
+
isolatedCargoMetadataEnv,
|
|
12
14
|
validateRightKitCargoContract,
|
|
13
15
|
} from "./cargo-contract.mjs";
|
|
14
16
|
import { assertAsrAdapterPair } from "./asr-artifact-adoption.mjs";
|
|
@@ -125,7 +127,7 @@ function readCargoManifestContract(manifestPath, label) {
|
|
|
125
127
|
{
|
|
126
128
|
cwd: path.dirname(manifestPath),
|
|
127
129
|
encoding: "utf8",
|
|
128
|
-
env:
|
|
130
|
+
env: isolatedCargoMetadataEnv(isolatedCargoHome),
|
|
129
131
|
windowsHide: true,
|
|
130
132
|
},
|
|
131
133
|
);
|
|
@@ -461,15 +463,16 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
461
463
|
assert.deepEqual(versions.stagedNpm, {
|
|
462
464
|
"@rightkit/ax": "0.2.0",
|
|
463
465
|
"@rightkit/git": "0.2.0",
|
|
466
|
+
"@rightkit/hooks": "0.1.0",
|
|
464
467
|
"@rightkit/legal": "0.3.0",
|
|
465
468
|
"@rightkit/legal-ui": "0.1.1",
|
|
466
469
|
"@rightkit/license": "0.1.6",
|
|
467
|
-
"@rightkit/release": "0.2.
|
|
470
|
+
"@rightkit/release": "0.2.62",
|
|
468
471
|
"@rightkit/qa": "0.2.0",
|
|
469
472
|
});
|
|
470
473
|
assert.deepEqual(versions.legacyNpm, {
|
|
471
474
|
"@rightkit/legal-ui": ["0.1.0"],
|
|
472
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55", "0.2.56"],
|
|
475
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55", "0.2.56", "0.2.61"],
|
|
473
476
|
"@rightkit/qa": ["0.1.0"],
|
|
474
477
|
});
|
|
475
478
|
assert.ok(
|
|
@@ -495,11 +498,26 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
495
498
|
getCurrentCargoVersionContract();
|
|
496
499
|
});
|
|
497
500
|
|
|
498
|
-
test("Cargo metadata uses
|
|
499
|
-
assert.equal(cargoExecutable("win32"), "
|
|
501
|
+
test("Cargo metadata uses native rustup on Windows", () => {
|
|
502
|
+
assert.equal(cargoExecutable("win32"), "rustup");
|
|
503
|
+
assert.deepEqual(cargoArguments(["metadata"], "win32"), ["run", "stable", "cargo", "metadata"]);
|
|
500
504
|
assert.equal(cargoExecutable("darwin"), "cargo");
|
|
501
505
|
});
|
|
502
506
|
|
|
507
|
+
test("Cargo metadata delegates controlled storage to managed RightKit", () => {
|
|
508
|
+
const ambient = {
|
|
509
|
+
CARGO_HOME: "/ambient/cargo",
|
|
510
|
+
CARGO_TARGET_DIR: "/ambient/target",
|
|
511
|
+
RIGHTKIT_BUILD_BROKER_SOCKET: "/managed/broker.sock",
|
|
512
|
+
};
|
|
513
|
+
assert.deepEqual(isolatedCargoMetadataEnv("/isolated/cargo", ambient), {
|
|
514
|
+
RIGHTKIT_BUILD_BROKER_SOCKET: "/managed/broker.sock",
|
|
515
|
+
});
|
|
516
|
+
assert.deepEqual(isolatedCargoMetadataEnv("/isolated/cargo", { CARGO_HOME: "/ambient/cargo" }), {
|
|
517
|
+
CARGO_HOME: "/isolated/cargo",
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
503
521
|
test("license v2 public vector is identical at every portable consumer boundary", () => {
|
|
504
522
|
const canonical = readFileSync(
|
|
505
523
|
path.join(workspace, "tools/rightkit/crates/rightkit-license/test-vectors/license-v2.json"),
|
package/rightkit-versions.json
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
"stagedNpm": {
|
|
16
16
|
"@rightkit/ax": "0.2.0",
|
|
17
17
|
"@rightkit/git": "0.2.0",
|
|
18
|
+
"@rightkit/hooks": "0.1.0",
|
|
18
19
|
"@rightkit/legal": "0.3.0",
|
|
19
20
|
"@rightkit/legal-ui": "0.1.1",
|
|
20
21
|
"@rightkit/license": "0.1.6",
|
|
21
|
-
"@rightkit/release": "0.2.
|
|
22
|
+
"@rightkit/release": "0.2.62",
|
|
22
23
|
"@rightkit/qa": "0.2.0"
|
|
23
24
|
},
|
|
24
25
|
"legacyNpm": {
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"0.2.53",
|
|
43
44
|
"0.2.54",
|
|
44
45
|
"0.2.55",
|
|
45
|
-
"0.2.56"
|
|
46
|
+
"0.2.56",
|
|
47
|
+
"0.2.61"
|
|
46
48
|
],
|
|
47
49
|
"@rightkit/qa": [
|
|
48
50
|
"0.1.0"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"generatedAt": "2026-08-10T04:42:36.444Z",
|
|
4
|
+
"workRoot": "C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR",
|
|
5
|
+
"apps": [
|
|
6
|
+
{
|
|
7
|
+
"key": "viewright",
|
|
8
|
+
"remote": "https://github.com/bogusyogi/viewright.git",
|
|
9
|
+
"appDir": ".",
|
|
10
|
+
"revision": "21a4171fa8add2d8114bc5f07498b60d7e8eafe5",
|
|
11
|
+
"packageManager": "pnpm@11.18.0",
|
|
12
|
+
"clone": {
|
|
13
|
+
"command": "git clone --depth 1 --single-branch https://github.com/bogusyogi/viewright.git C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright",
|
|
14
|
+
"status": 0,
|
|
15
|
+
"stdout": "",
|
|
16
|
+
"stderr": "Cloning into 'C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright'...\nUpdating files: 91% (1902/2080)\rUpdating files: 92% (1914/2080)\rUpdating files: 93% (1935/2080)\rUpdating files: 94% (1956/2080)\rUpdating files: 95% (1976/2080)\rUpdating files: 96% (1997/2080)\rUpdating files: 97% (2018/2080)\rUpdating files: 98% (2039/2080)\rUpdating files: 99% (2060/2080)\rUpdating files: 100% (2080/2080)\rUpdating files: 100% (2080/2080), done."
|
|
17
|
+
},
|
|
18
|
+
"install": {
|
|
19
|
+
"command": "C:\\nvm4w\\nodejs\\node.exe C:\\nvm4w\\nodejs\\node_modules\\pnpm\\bin\\pnpm.mjs install --frozen-lockfile",
|
|
20
|
+
"status": 0,
|
|
21
|
+
"stdout": "✓ Lockfile passes supply-chain policies (verified 8h ago)\nLockfile is up to date, resolution step is skipped\nProgress: resolved 1, reused 0, downloaded 0, added 0\nPackages: +619\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\nProgress: resolved 619, reused 0, downloaded 0, added 0\nProgress: resolved 619, reused 60, downloaded 0, added 0\nPackages are hard linked from the content-addressable store to the virtual store.\n Content-addressable store is at: C:\\Users\\adrds\\AppData\\Local\\pnpm\\store\\v11\n Virtual store is at: node_modules/.pnpm\nProgress: resolved 619, reused 573, downloaded 7, added 7\nProgress: resolved 619, reused 574, downloaded 9, added 10\nProgress: resolved 619, reused 574, downloaded 22, added 11\nProgress: resolved 619, reused 574, downloaded 23, added 11\nProgress: resolved 619, reused 574, downloaded 26, added 14\nProgress: resolved 619, reused 574, downloaded 30, added 24\nProgress: resolved 619, reused 574, downloaded 31, added 57\nProgress: resolved 619, reused 574, downloaded 34, added 128\nProgress: resolved 619, reused 574, downloaded 38, added 135\nProgress: resolved 619, reused 574, downloaded 38, added 136\nProgress: resolved 619, reused 574, downloaded 39, added 141\nProgress: resolved 619, reused 574, downloaded 39, added 148\nProgress: resolved 619, reused 574, downloaded 39, added 182\nProgress: resolved 619, reused 574, downloaded 40, added 201\nProgress: resolved 619, reused 574, downloaded 41, added 218\nProgress: resolved 619, reused 574, downloaded 41, added 242\nProgress: resolved 619, reused 574, downloaded 42, added 312\nProgress: resolved 619, reused 574, downloaded 42, added 371\nProgress: resolved 619, reused 574, downloaded 42, added 430\nProgress: resolved 619, reused 574, downloaded 42, added 486\nProgress: resolved 619, reused 574, downloaded 43, added 543\nProgress: resolved 619, reused 574, downloaded 43, added 578\nProgress: resolved 619, reused 574, downloaded 44, added 596\nProgress: resolved 619, reused 574, downloaded 44, added 611\nProgress: resolved 619, reused 574, downloaded 44, added 612\nProgress: resolved 619, reused 574, downloaded 44, added 613\nProgress: resolved 619, reused 574, downloaded 44, added 615\nProgress: resolved 619, reused 574, downloaded 44, added 616\nProgress: resolved 619, reused 574, downloaded 44, added 617\nProgress: resolved 619, reused 574, downloaded 44, added 618\nProgress: resolved 619, reused 574, downloaded 45, added 618\nProgress: resolved 619, reused 574, downloaded 45, added 619\nProgress: resolved 619, reused 574, downloaded 45, added 619, done\n\ndependencies:\n+ @codemirror/autocomplete 6.20.3\n+ @codemirror/commands 6.10.4\n+ @codemirror/lang-html 6.4.11\n+ @codemirror/lang-javascript 6.2.5\n+ @codemirror/lang-markdown 6.5.0\n+ @codemirror/language 6.12.4\n+ @codemirror/lint 6.9.7\n+ @codemirror/search 6.7.1\n+ @codemirror/state 6.7.1\n+ @codemirror/view 6.43.6\n+ @eigenpal/docx-editor-agents @eigenpal/docx-editor-agents@file:vendor/docx-editor/packages/agents(react@19.2.7)\n+ @eigenpal/docx-editor-core @eigenpal/docx-editor-core@file:vendor/docx-editor/packages/core(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)\n+ @eigenpal/docx-editor-i18n @eigenpal/docx-editor-i18n@file:vendor/docx-editor/packages/i18n\n+ @eigenpal/docx-editor-react @eigenpal/docx-editor-react@file:vendor/docx-editor/packages/react(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)\n+ @lezer/highlight 1.2.3\n+ @mdx-js/mdx 3.1.1\n+ @phosphor-icons/react 2.1.10\n+ @radix-ui/react-select 2.3.2\n+ @rightkit/legal-ui 0.1.0\n+ @rightkit/license 0.1.6\n+ @rightkit/logs 0.1.3\n+ @rightkit/tauri 0.1.0\n+ @rightkit/updates 0.2.3\n+ @tauri-apps/api 2.11.1\n+ @tauri-apps/plugin-http 2.5.9\n+ @tauri-apps/plugin-process 2.3.1\n+ @tauri-apps/plugin-updater 2.10.1\n+ clsx 2.1.1\n+ docxtemplater 3.69.0\n+ dompurify 3.4.13\n+ fabric 7.4.0\n+ github-slugger 2.0.0\n+ jszip 3.10.1\n+ katex 0.17.0\n+ mermaid 11.16.1\n+ pdfjs-dist 6.2.108\n+ pizzip 3.2.0\n+ prosemirror-commands 1.7.1\n+ prosemirror-dropcursor 1.8.2\n+ prosemirror-history 1.5.0\n+ prosemirror-keymap 1.2.3\n+ prosemirror-model 1.25.10\n+ prosemirror-state 1.4.4\n+ prosemirror-tables 1.8.5\n+ prosemirror-transform 1.12.0\n+ prosemirror-view 1.42.0\n+ react 19.2.7\n+ react-dom 19.2.7\n+ react-image-crop 11.1.2\n+ rehype-stringify 10.0.1\n+ remark-frontmatter 5.0.0\n+ remark-gfm 4.0.1\n+ remark-math 6.0.0\n+ remark-parse 11.0.0\n+ remark-rehype 11.1.2\n+ remark-smartypants 3.0.2\n+ shiki 4.3.1\n+ sonner 2.0.7\n+ sucrase 3.35.1\n+ unified 11.0.5\n+ xml-js 1.6.11\n+ yaml 2.9.0\n\ndevDependencies:\n+ @biomejs/biome 2.5.3\n+ @rightkit/legal 0.3.0\n+ @rightkit/release 0.2.50\n+ @tailwindcss/vite 4.3.2\n+ @tauri-apps/cli 2.11.4\n+ @testing-library/dom 10.4.1\n+ @testing-library/jest-dom 6.9.1\n+ @testing-library/react 16.3.2\n+ @types/mdast 4.0.4\n+ @types/react 19.2.17\n+ @types/react-dom 19.2.3\n+ @types/ws 8.18.1\n+ @vitejs/plugin-react 6.0.3\n+ happy-dom 20.10.6\n+ jscpd 5.0.12\n+ jsdom 29.1.1\n+ knip 6.25.0\n+ tailwindcss 4.3.2\n+ typescript 6.0.3\n+ vite 8.1.4\n+ vitest 4.1.10\n+ ws 8.21.0\n\nDone in 40.8s using pnpm v11.18.0",
|
|
22
|
+
"stderr": ""
|
|
23
|
+
},
|
|
24
|
+
"doctor": {
|
|
25
|
+
"command": "C:\\nvm4w\\nodejs\\node.exe C:\\nvm4w\\nodejs\\node_modules\\pnpm\\bin\\pnpm.mjs release:doctor",
|
|
26
|
+
"status": 0,
|
|
27
|
+
"stdout": "right-release 0.2.50\nconfig: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\\right-release.config.mjs\napp: viewright\nplatform: win\ntier: <required for release/publish>\npackageManager: pnpm\nworkdir: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\nhardeningscan: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\\node_modules\\.pnpm\\@rightkit+release@0.2.50\\node_modules\\@rightkit\\release\\hardeningscan.mjs\nlegal: C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright\\legal\\legal-manifest.json\nlegalAcceptance: viewright-2026-07-17-v3\nlegalManifestSha256: 035ea7cbd040ef001dbdc1385f114c8bb126178fea9a9f705016a726f96a7830\nsign: src-tauri/target/release/bundle/nsis/ViewRight_0.1.60_x64-setup.exe\npreflight:\n [ok ] target-bridge: src-tauri/target is ready for the shared cache bridge\n [ok ] version: 0.1.60 is free to build\n [ok ] windows-sdk: makeappx.exe from SDK 10.0.26100.0\n [ok ] signtool: signtool.exe from SDK 10.0.26100.0\n [ok ] sccache: sccache 0.17.0\n [ok ] disk: 628.7GB free",
|
|
28
|
+
"stderr": "$ right-release doctor"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
package/upload-release.mjs
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import {
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { prepareGitHubRelease, publishGitHubRelease, repositoryFromRemote } from "./github-release.mjs";
|
|
7
7
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { verifySealedRelease } from "./release-state.mjs";
|
|
9
9
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
10
10
|
|
|
11
|
-
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const PUBLISH_UPDATE = path.join(TOOL_ROOT, "publish-update.mjs");
|
|
13
|
-
const HARDENING = path.join(TOOL_ROOT, "hardeningscan.mjs");
|
|
14
|
-
const SIGN_WINDOWS = path.join(TOOL_ROOT, "sign-windows.mjs");
|
|
15
|
-
const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
|
|
16
|
-
const PUBLIC_BASE = (process.env.RIGHTAPPS_PUBLIC_DOWNLOAD_BASE || "https://pub-6c73208d46c245a9b4881d5e02f6b618.r2.dev").replace(/\/$/, "");
|
|
17
|
-
const API_BASE = (process.env.RIGHTAPPS_API_URL || "https://api.spoares.com").replace(/\/$/, "");
|
|
18
11
|
const args = process.argv.slice(2);
|
|
19
12
|
let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
|
|
20
13
|
let releaseId = "";
|
|
21
14
|
let tier = "";
|
|
15
|
+
let repo = "";
|
|
16
|
+
let configName = "right-release.config.mjs";
|
|
22
17
|
let dryRun = false;
|
|
23
18
|
|
|
24
|
-
for (let
|
|
25
|
-
const arg = args[
|
|
26
|
-
if (arg === "--platform") platform = args[++
|
|
27
|
-
else if (arg === "--release") releaseId = args[++
|
|
28
|
-
else if (arg === "--tier") tier = args[++
|
|
19
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
20
|
+
const arg = args[index];
|
|
21
|
+
if (arg === "--platform") platform = args[++index];
|
|
22
|
+
else if (arg === "--release") releaseId = args[++index];
|
|
23
|
+
else if (arg === "--tier") tier = args[++index];
|
|
29
24
|
else if (arg.startsWith("--tier=")) tier = arg.slice("--tier=".length);
|
|
25
|
+
else if (arg === "--repo") repo = args[++index];
|
|
26
|
+
else if (arg === "--config") configName = args[++index];
|
|
30
27
|
else if (arg === "--dry-run") dryRun = true;
|
|
31
28
|
else if (arg === "-h" || arg === "--help") usage(0);
|
|
32
29
|
else fail(`unknown argument: ${arg}`);
|
|
@@ -43,183 +40,52 @@ assertCleanSource({
|
|
|
43
40
|
commandId: "right-release upload",
|
|
44
41
|
});
|
|
45
42
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
46
|
-
const
|
|
47
|
-
const sealed = verifySealedRelease(sealedDir);
|
|
43
|
+
const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
|
|
48
44
|
if (sealed.manifest.commit !== candidateCommit) fail("sealed release was built from a different commit than the current checkout");
|
|
45
|
+
|
|
49
46
|
const stateRoot = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
|
|
50
|
-
const backupRoot = path.join(stateRoot, "rollback", tier);
|
|
51
47
|
const verifiedMarker = path.join(stateRoot, `verified-${tier}.json`);
|
|
52
48
|
if (existsSync(verifiedMarker)) {
|
|
53
49
|
console.log(`right-release upload: already verified ${releaseId} tier=${tier}`);
|
|
54
50
|
process.exit(0);
|
|
55
51
|
}
|
|
56
|
-
if (!dryRun && !process.env.CLOUDFLARE_API_TOKEN) fail("CLOUDFLARE_API_TOKEN is required before R2 mutation");
|
|
57
52
|
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
const configPath = path.resolve(configName);
|
|
54
|
+
if (existsSync(configPath)) {
|
|
55
|
+
const releaseConfig = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
|
|
56
|
+
if (releaseConfig?.distribution?.provider && releaseConfig.distribution.provider !== "github-releases") {
|
|
57
|
+
fail("release config distribution.provider must be github-releases");
|
|
58
|
+
}
|
|
59
|
+
repo ||= releaseConfig?.distribution?.repository ?? "";
|
|
60
|
+
}
|
|
61
|
+
repo ||= repositoryFromRemote(repoRoot);
|
|
62
|
+
const plan = prepareGitHubRelease({ repoRoot, releaseId, platform, repo });
|
|
63
|
+
const result = publishGitHubRelease(plan, { repo, dryRun });
|
|
64
|
+
if (!dryRun) writeJson(verifiedMarker, {
|
|
65
|
+
schema: 1,
|
|
60
66
|
releaseId,
|
|
61
67
|
platform,
|
|
62
68
|
tier,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
runChecked(process.execPath, [HARDENING, ...files], repoRoot);
|
|
68
|
-
},
|
|
69
|
-
backup: async (release, routes) => backupStableObjects(release, routes),
|
|
70
|
-
upload: async (release) => publishSealed(release),
|
|
71
|
-
register: async () => {},
|
|
72
|
-
verifyRemote: async (release, routes) => verifyUploaded(release, routes),
|
|
73
|
-
restore: async (_release, routes) => restoreStableObjects(routes),
|
|
74
|
-
discardBackup: async () => rmSync(backupRoot, { recursive: true, force: true }),
|
|
75
|
-
},
|
|
69
|
+
provider: "github-releases",
|
|
70
|
+
repository: repo,
|
|
71
|
+
tag: result.tag,
|
|
72
|
+
verifiedAt: new Date().toISOString(),
|
|
76
73
|
});
|
|
77
|
-
|
|
78
|
-
console.log(`right-release upload: ${dryRun ? "dry-run " : ""}verified ${releaseId} tier=${tier}`);
|
|
79
|
-
|
|
80
|
-
function verifyPlatformTrust(release) {
|
|
81
|
-
if (dryRun) return;
|
|
82
|
-
const artifacts = release.manifest.files.filter((file) => file.role !== "updater-signature");
|
|
83
|
-
if (platform === "win") {
|
|
84
|
-
for (const artifact of artifacts) {
|
|
85
|
-
const file = path.join(release.sealedDir, artifact.name);
|
|
86
|
-
runChecked(process.execPath, [SIGN_WINDOWS, "--verify-only", file], repoRoot);
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
for (const artifact of artifacts.filter((file) => /\.dmg$/i.test(file.name))) {
|
|
90
|
-
const file = path.join(release.sealedDir, artifact.name);
|
|
91
|
-
runChecked("codesign", ["--verify", "--strict", "--verbose=2", file], repoRoot);
|
|
92
|
-
runChecked("xcrun", ["stapler", "validate", file], repoRoot);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function backupStableObjects(release, routes) {
|
|
98
|
-
if (dryRun) return;
|
|
99
|
-
if (existsSync(path.join(backupRoot, "backup-manifest.json"))) restoreStableObjects(routes);
|
|
100
|
-
mkdirSync(backupRoot, { recursive: true });
|
|
101
|
-
const entries = [];
|
|
102
|
-
for (const route of uniqueRoutes(routes)) {
|
|
103
|
-
const bucket = bucketName(route.bucket);
|
|
104
|
-
const file = path.join(backupRoot, `${createHash("sha256").update(`${bucket}/${route.key}`).digest("hex").slice(0, 12)}.bin`);
|
|
105
|
-
const result = wrangler(["r2", "object", "get", `${bucket}/${route.key}`, "--file", file, "--remote"]);
|
|
106
|
-
if (result.status === 0) entries.push({ bucket: route.bucket, key: route.key, existed: true, file: path.basename(file), sha256: hashFile(file) });
|
|
107
|
-
else if (/not found|does not exist|404/i.test(`${result.stdout}\n${result.stderr}`)) entries.push({ bucket: route.bucket, key: route.key, existed: false, file: null, sha256: null });
|
|
108
|
-
else throw new Error(`failed to back up R2 object ${bucket}/${route.key}: ${result.stderr || result.stdout}`);
|
|
109
|
-
}
|
|
110
|
-
writeJson(path.join(backupRoot, "backup-manifest.json"), { schema: 1, releaseId: release.manifest.releaseId, entries });
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function publishSealed(release) {
|
|
114
|
-
if (dryRun) return;
|
|
115
|
-
const generated = path.join(stateRoot, `sealed-upload-${tier}.config.mjs`);
|
|
116
|
-
const config = sealedUploadConfig(release);
|
|
117
|
-
writeFileSync(generated, `export default ${JSON.stringify(config, null, 2)};\n`);
|
|
118
|
-
runChecked(process.execPath, [PUBLISH_UPDATE, "--config", generated, "--platform", platform], repoRoot, { ...process.env, RIGHT_RELEASE_TIER: tier });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function sealedUploadConfig(release) {
|
|
122
|
-
const fileByName = new Map(release.manifest.files.map((file) => [file.name, path.join(release.sealedDir, file.name)]));
|
|
123
|
-
const patchRoutes = release.manifest.routes.patch ?? [];
|
|
124
|
-
const updateRoutes = release.manifest.routes.update ?? [];
|
|
125
|
-
const installers = patchRoutes.filter((route) => route.role === "installer").map((route) => ({ file: fileByName.get(route.name), key: route.key }));
|
|
126
|
-
const updaterRoutes = updateRoutes.map((route) => {
|
|
127
|
-
const patch = patchRoutes.find((candidate) => candidate.name === route.name && candidate.platform === route.platform);
|
|
128
|
-
return { file: fileByName.get(route.name), signature: fileByName.get(route.signature), platform: route.platform, key: route.key, patchKey: patch?.key };
|
|
129
|
-
});
|
|
130
|
-
return {
|
|
131
|
-
schema: 1,
|
|
132
|
-
app: release.manifest.app,
|
|
133
|
-
version: release.manifest.version,
|
|
134
|
-
channel: "stable",
|
|
135
|
-
targets: { [platform]: { installer: { artifacts: installers }, updater: { artifacts: updaterRoutes } } },
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function verifyUploaded(release, routes) {
|
|
140
|
-
if (dryRun) return;
|
|
141
|
-
const files = new Map(release.manifest.files.map((file) => [file.name, file]));
|
|
142
|
-
for (const route of uniqueRoutes(routes)) {
|
|
143
|
-
const expected = files.get(route.name);
|
|
144
|
-
if (!expected) throw new Error(`route references unsealed file: ${route.name}`);
|
|
145
|
-
const verifyFile = path.join(stateRoot, `remote-${createHash("sha256").update(route.key).digest("hex").slice(0, 12)}.bin`);
|
|
146
|
-
if (route.bucket === "public") {
|
|
147
|
-
const response = await fetch(`${PUBLIC_BASE}/${route.key}`, { cache: "no-store" });
|
|
148
|
-
if (!response.ok) throw new Error(`public artifact verification failed: ${response.status} ${route.key}`);
|
|
149
|
-
writeFileSync(verifyFile, Buffer.from(await response.arrayBuffer()));
|
|
150
|
-
} else {
|
|
151
|
-
const result = wrangler(["r2", "object", "get", `${bucketName(route.bucket)}/${route.key}`, "--file", verifyFile, "--remote"]);
|
|
152
|
-
if (result.status !== 0) throw new Error(`private artifact verification failed: ${route.key}`);
|
|
153
|
-
}
|
|
154
|
-
if (statSync(verifyFile).size !== expected.sizeBytes || hashFile(verifyFile) !== expected.sha256) throw new Error(`remote hash mismatch: ${route.key}`);
|
|
155
|
-
rmSync(verifyFile, { force: true });
|
|
156
|
-
}
|
|
157
|
-
if (!routes.some((route) => route.role === "updater")) return;
|
|
158
|
-
const apiPlatform = platform === "win" ? "windows-x86_64" : "darwin-aarch64";
|
|
159
|
-
const response = await fetch(`${API_BASE}/v1/apps/${release.manifest.app}/releases/latest.json?platform=${apiPlatform}`, { cache: "no-store" });
|
|
160
|
-
if (!response.ok) throw new Error(`updater API verification failed: ${response.status}`);
|
|
161
|
-
const body = await response.json();
|
|
162
|
-
if (body.version !== release.manifest.version) throw new Error(`updater API version mismatch: expected ${release.manifest.version}, got ${body.version}`);
|
|
163
|
-
const expectedRoutes = new Set(routes.map((route) => route.key));
|
|
164
|
-
const urls = Object.values(body.platforms ?? {}).map((entry) => entry.url).filter(Boolean);
|
|
165
|
-
if (!urls.some((url) => [...expectedRoutes].some((key) => url.endsWith(key)))) throw new Error("updater API returned the wrong artifact URL");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function restoreStableObjects(routes) {
|
|
169
|
-
const manifestPath = path.join(backupRoot, "backup-manifest.json");
|
|
170
|
-
if (!existsSync(manifestPath)) return;
|
|
171
|
-
const backup = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
172
|
-
for (const entry of backup.entries) {
|
|
173
|
-
if (entry.existed) {
|
|
174
|
-
const file = path.join(backupRoot, entry.file);
|
|
175
|
-
if (hashFile(file) !== entry.sha256) throw new Error(`rollback copy hash mismatch: ${entry.key}`);
|
|
176
|
-
runChecked(process.execPath, [UPLOAD, file, entry.key, entry.bucket], repoRoot);
|
|
177
|
-
} else {
|
|
178
|
-
const result = wrangler(["r2", "object", "delete", `${bucketName(entry.bucket)}/${entry.key}`, "--remote"]);
|
|
179
|
-
if (result.status !== 0 && !/not found|404/i.test(`${result.stdout}\n${result.stderr}`)) throw new Error(`rollback delete failed: ${entry.key}`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
rmSync(backupRoot, { recursive: true, force: true });
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function uniqueRoutes(routes) {
|
|
186
|
-
return [...new Map(routes.map((route) => [`${route.bucket}/${route.key}`, route])).values()];
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function bucketName(bucket) {
|
|
190
|
-
return bucket === "public" ? "rightapps-downloads" : "rightapps-updates";
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function wrangler(runArgs) {
|
|
194
|
-
return spawnSync("pnpm", ["dlx", "wrangler@4", ...runArgs], { cwd: repoRoot, env: process.env, encoding: "utf8", windowsHide: true, shell: process.platform === "win32" });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function hashFile(file) {
|
|
198
|
-
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
199
|
-
}
|
|
74
|
+
console.log(`right-release upload: ${dryRun ? "dry-run " : ""}${result.status} ${releaseId} tier=${tier} repo=${repo}`);
|
|
200
75
|
|
|
201
76
|
function git(cwd, runArgs) {
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function commandOutput(cmd, runArgs, cwd = process.cwd()) {
|
|
206
|
-
const result = spawnSync(cmd, runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
207
|
-
if (result.status !== 0) fail(`${cmd} ${runArgs.join(" ")} failed: ${result.stderr}`);
|
|
77
|
+
const result = spawnSync("git", runArgs, { cwd, encoding: "utf8", windowsHide: true });
|
|
78
|
+
if (result.status !== 0) fail(`git ${runArgs.join(" ")} failed: ${result.stderr}`);
|
|
208
79
|
return result.stdout.trim();
|
|
209
80
|
}
|
|
210
81
|
|
|
211
|
-
function runChecked(cmd, runArgs, cwd, env = process.env) {
|
|
212
|
-
const result = spawnSync(cmd, runArgs, { cwd, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
213
|
-
if (result.status !== 0) throw new Error(`${cmd} exited ${result.status}`);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
82
|
function writeJson(file, value) {
|
|
217
83
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
218
84
|
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
219
85
|
}
|
|
220
86
|
|
|
221
87
|
function usage(code) {
|
|
222
|
-
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--dry-run]");
|
|
88
|
+
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]");
|
|
223
89
|
process.exit(code);
|
|
224
90
|
}
|
|
225
91
|
|