@rightkit/release 0.6.16 → 0.6.20

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.
Files changed (64) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +469 -102
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +247 -40
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +107 -9
  52. package/snapshot-check.mjs +2 -0
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar.mjs +17 -3
  58. package/suite-signing.mjs +40 -4
  59. package/swift-lock.mjs +334 -0
  60. package/test-command.mjs +13 -3
  61. package/tool-build.mjs +156 -0
  62. package/unit-gate.mjs +9 -2
  63. package/windows-identity-plan.mjs +119 -0
  64. package/windows-identity.mjs +136 -0
package/test-command.mjs CHANGED
@@ -30,6 +30,7 @@ import path from "node:path";
30
30
  import { pathToFileURL } from "node:url";
31
31
  import { spawn } from "./background-process.mjs";
32
32
  import { carriedCargoMapOf, carriedLedgerIds, carriedOutputDirsSince } from "./carried-cargo.mjs";
33
+ import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
33
34
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
34
35
  import { defaultReleaseConfigFile } from "./release-config.mjs";
35
36
  import { fsListRustFiles } from "./signing-files.mjs";
@@ -167,6 +168,11 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
167
168
  }
168
169
 
169
170
  /** The refusal code of a planned `rightkit-qa journeys run` suite (GB-79). */
171
+ /** RIGHTKIT_GATE_TARGET_DIRS: the absolute directories the gate pinned; none when unset or malformed. */
172
+ function directOutputDirs(env) {
173
+ try { const dirs = JSON.parse(env[GATE_TARGET_DIRS_ENV] ?? "[]"); return Array.isArray(dirs) ? dirs.filter((d) => typeof d === "string" && path.isAbsolute(d)) : []; } catch { return []; }
174
+ }
175
+
170
176
  export const QA_JOURNEYS_UNSUPPORTED = "RK_QA_JOURNEYS_PREBUILT_UNSUPPORTED";
171
177
  /** True when a suite step is `rightkit-qa journeys run ...` (by command or by node script name, extra args included). */
172
178
  export function qaJourneysRunStep(step, extraArgs = []) {
@@ -187,8 +193,10 @@ export function eventPrefix(config) {
187
193
  * Resolves `{ exitCode, results: [{ suite, exitCode, durationMs, skipped?, reason?, error? }] }`; a signal (cancellation) rejects.
188
194
  * A `sign` step (suite-signing.mjs) runs IN THIS PROCESS, never through `door` (it is no command and never a broker job); it signs what an earlier
189
195
  * suite built, named by that suite's verified carried receipts, and only a success sets RIGHTKIT_QA_APP_BINARY for the suites after it.
196
+ * On a direct host (no carrier) a suite's outputs are the Cargo output directories the gate pinned for the stage (RIGHTKIT_GATE_TARGET_DIRS).
197
+ * `within({ outputs, env })`, when given, runs once every suite passed and before the signed copies are removed; its result is returned as `within`.
190
198
  */
191
- export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform, signDeps = {}, signStep = runSignStep, snapshotStage = Boolean(carriedCargoMapOf(env)?.map), qaCapability = (qa) => qaPrebuiltCapability(qa, { env }) } = {}) {
199
+ export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform, signDeps = {}, signStep = runSignStep, within = null, snapshotStage = Boolean(carriedCargoMapOf(env)?.map), qaCapability = (qa) => qaPrebuiltCapability(qa, { env }) } = {}) {
192
200
  const results = [];
193
201
  // One signing policy, enforced here whatever the config's format (the route probe reads JSON configs only; a legacy .mjs config is judged now): an
194
202
  // unsigned or ad hoc product build, or an identifier override, is refused before any suite runs (rightkit#1303). Mac and Dell only.
@@ -294,14 +302,16 @@ export async function runSuites(plan, { appRoot, prefix = "test", env = process.
294
302
  const durationMs = Date.now() - started;
295
303
  // Only a suite that passed hands its receipts to a sign step.
296
304
  if (exitCode === 0) {
297
- const dirs = carriedOutputDirsSince(env, before);
305
+ const dirs = carriedCargoMapOf(env)?.map ? carriedOutputDirsSince(env, before) : directOutputDirs(env);
298
306
  for (const key of new Set([item.name, item.group])) outputs.set(key, [...new Set([...(outputs.get(key) ?? []), ...dirs])]);
299
307
  }
300
308
  log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
301
309
  results.push({ suite: item.name, exitCode, durationMs, ...(failure ? { error: String(failure.message ?? failure) } : {}) });
302
310
  record(item, exitCode === 0 ? "passed" : "failed");
303
311
  }
304
- return { exitCode: results.some((r) => r.skipped !== true && r.exitCode !== 0) || results.some((r) => r.skipped) ? firstFailure() : 0, results };
312
+ const exitCode = results.some((r) => r.skipped !== true && r.exitCode !== 0) || results.some((r) => r.skipped) ? firstFailure() : 0;
313
+ if (exitCode !== 0 || !within) return { exitCode, results };
314
+ return { exitCode, results, within: await within({ outputs, env: suiteEnv }) };
305
315
  } finally {
306
316
  // The signed copies live only as long as the run that signed them.
307
317
  for (const dir of signedDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
package/tool-build.mjs ADDED
@@ -0,0 +1,156 @@
1
+ // The gate's tool route: `rightkit-gate --to tool --unit cargo:rightkit-qa [--no-broker <exception-id>]`.
2
+ //
3
+ // Release QA runs only an ENROLLED rightkit-qa runner of the exact version an app's `qa.version` names (qa-runner.mjs). The unit stages cannot
4
+ // make that binary: `--to compile|package --unit cargo:rightkit-qa` runs the unit's test plan and packs a .crate, and refuses a version that is
5
+ // already on crates.io (local-publish.mjs); `cargo install` is denied (cargo-shapes.mjs). This route is the one sanctioned way to produce it:
6
+ //
7
+ // 1. the unit's class A check (the compile tier), exactly as a unit stage runs it;
8
+ // 2. `cargo build --release --locked --bin <bin>` in the unit's own Cargo workspace of the selected commit's snapshot, through the same executor,
9
+ // cargo wrapper or carrier and target-directory binding as every other unit compile (unit-gate.mjs / bindUnitCargo). There is NO registry-version
10
+ // check: a tool build of a published version is the point of it, and it publishes nothing;
11
+ // 3. the produced binary is enrolled (qa-runner.mjs `enrollQaRunner`): version = the crate's COMMITTED version, answered by the binary's own
12
+ // `--version`, a COPY recorded with its sha256 under the managed data root. The enrolled path and sha256 are printed so the owner can add the
13
+ // admission-policy entry (`right-release qa policy approve-runner`). Admission itself is unchanged and stays the owner's.
14
+ //
15
+ // `cargo:rightkit-credentials` takes the same route and is enrolled as the managed OS credential provider (credential-provider.mjs) that updater signing runs.
16
+ // Only units named in TOOL_UNITS have this route. The route never uploads, signs or publishes, and it enrols only on a Mac or Windows host (the platforms
17
+ // a runner is enrolled for: its bytes are executed here to read their version).
18
+ import crypto from "node:crypto";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { endRun, startRun } from "./gate-stages.mjs";
22
+ import { runCargoSync } from "./managed-cargo.mjs";
23
+ import { resolveManagedRoots } from "./managed-roots.mjs";
24
+ import { CredentialProviderError, enrollCredentialProvider } from "./credential-provider.mjs";
25
+ import { QaRunnerError, enrollQaRunner } from "./qa-runner.mjs";
26
+ import { knownRedEntry, resolveUnit } from "./unit-gate.mjs";
27
+
28
+ export const TOOL_STAGE = "tool";
29
+ export const TOOL_REPORT_SCHEMA = "rightkit.tool-build-report/1";
30
+ /** The units that have a tool route: the crate, the binary its manifest declares, and what the binary is enrolled as. */
31
+ export const TOOL_UNITS = Object.freeze({
32
+ "cargo:rightkit-qa": Object.freeze({ crate: "rightkit-qa", bin: "rightkit-qa", enrols: "qa-runner" }),
33
+ "cargo:rightkit-credentials": Object.freeze({ crate: "rightkit-credentials", bin: "rightkit-credentials", enrols: "credential-provider" }),
34
+ });
35
+ const HOST_PLATFORM = Object.freeze({ darwin: "mac", win32: "win" });
36
+ const BUILD_TIMEOUT_MS = 30 * 60 * 1000;
37
+
38
+ export class ToolBuildError extends Error {
39
+ constructor(code, message) { super(`${code}: ${message}`); this.name = "ToolBuildError"; this.code = code; }
40
+ }
41
+ const refuse = (code, message) => { throw new ToolBuildError(code, message); };
42
+
43
+ /** The cargo arguments of the build (no `cargo`): the one shape the broker admits as a compile and a stage grant carries (cargo-shapes.mjs). */
44
+ export const toolBuildArgs = (bin) => ["build", "--release", "--locked", "--bin", bin];
45
+
46
+ /**
47
+ * What `--to tool --unit <selector>` builds on `platform` (a `process.platform` value), decided before ownership, a snapshot or any process:
48
+ * `{ selector, crate, bin, enrols, cargoDir, argv, executable, runnerPlatform }`, or a typed ToolBuildError.
49
+ */
50
+ export function toolPlan({ selector, platform }) {
51
+ const unit = Object.hasOwn(TOOL_UNITS, selector ?? "") ? TOOL_UNITS[selector] : null;
52
+ if (!unit) refuse("RK_TOOL_UNIT_UNSUPPORTED", `${JSON.stringify(selector ?? null)} has no tool route (${Object.keys(TOOL_UNITS).join(", ")}); \`--to tool\` builds only a unit named there`);
53
+ const runnerPlatform = HOST_PLATFORM[platform];
54
+ if (!runnerPlatform) refuse("RK_TOOL_HOST", `a ${unit.bin} runner is built and enrolled on a Mac or a Windows host (this is ${platform}): its bytes are executed there to read their version, and a runner is enrolled per platform`);
55
+ return { selector, ...unit, cargoDir: `crates/${unit.crate}`, argv: toolBuildArgs(unit.bin), executable: runnerPlatform === "win" ? `${unit.bin}.exe` : unit.bin, runnerPlatform };
56
+ }
57
+
58
+ /** The pinned target directory of the Cargo workspace that holds `cargoDir`: its own entry, else the nearest enclosing one (a crate that is a member of a parent workspace). Null when none is pinned. */
59
+ export function pinnedTargetDir(targetDirs, cargoDir) {
60
+ for (let dir = cargoDir; ; dir = path.posix.dirname(dir)) {
61
+ if (typeof targetDirs?.[dir] === "string" && targetDirs[dir]) return targetDirs[dir];
62
+ if (dir === "." || dir === "/" || dir === "") return null;
63
+ }
64
+ }
65
+
66
+ const sha256OfFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
67
+
68
+ /**
69
+ * The built binary under `outputDir` (the target directory: the carried receipt's `outputDir` on a broker host, the checkout's pinned target on a direct
70
+ * one): `<outputDir>/release/<executable>`, a regular file the build just wrote, never a link. Returns its path or throws.
71
+ */
72
+ export function builtToolBinary({ outputDir, executable }) {
73
+ if (typeof outputDir !== "string" || !path.isAbsolute(outputDir)) refuse("RK_TOOL_NO_OUTPUT_DIR", `the build reported no absolute output directory (${JSON.stringify(outputDir ?? null)}); the produced binary cannot be located, so nothing was enrolled`);
74
+ const file = path.join(outputDir, "release", executable);
75
+ let meta;
76
+ try { meta = fs.lstatSync(file); } catch (error) { refuse("RK_TOOL_NO_BINARY", `the build succeeded but ${file} does not exist (${error.code ?? error.message}); nothing was enrolled`); }
77
+ if (meta.isSymbolicLink() || !meta.isFile()) refuse("RK_TOOL_NO_BINARY", `${file} is not a regular file; nothing was enrolled`);
78
+ return file;
79
+ }
80
+
81
+ /**
82
+ * Runs the tool route for one unit of the selected commit's snapshot. `root` is the snapshot, `commonDir` the checkout's git directory. `check(tier)`
83
+ * is the door's class A run and `stageGuard` its ledger verify around each stage (as runUnitGate takes them); `envExtra` is the Cargo binding
84
+ * (bindUnitCargo's env) and `targetDirs` its `{ [cargoDir]: checkout target directory }` for a direct host. Everything a test replaces is a parameter:
85
+ * `run` (the cargo call: managed-cargo's runCargoSync, which carries it through the broker or runs it direct), `enroll`, `resolve`, `dataRoot`.
86
+ * Returns the exit code (0, or 1 with the reason printed).
87
+ */
88
+ export async function runUnitTool({
89
+ selector, root, commonDir, commit, tree, executor, env, platform, check, source = null, stageGuard = null, envExtra = {}, targetDirs = {},
90
+ resolve = resolveUnit, run = runCargoSync, enroll = enrollQaRunner, enrollProvider = enrollCredentialProvider, dataRoot = undefined, readKnownRed = null,
91
+ out = console.log, err = console.error, writeReport = null,
92
+ }) {
93
+ let plan; let unit;
94
+ try {
95
+ plan = toolPlan({ selector, platform });
96
+ unit = resolve(selector, root);
97
+ const red = knownRedEntry({ unit, root, platform, ...(readKnownRed ? { readText: readKnownRed } : {}) });
98
+ if (red) refuse("RK_TOOL_KNOWN_RED", `${selector} is listed in ci/known-red.json for ${red.host} (${red.key}: ${red.reason}); a known-red unit is not built. Fix the defect and remove the entry first`);
99
+ } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
100
+ const report = { schema: TOOL_REPORT_SCHEMA, unit, commit, tree, executor: executor?.kind ?? null, stages: {} };
101
+ const save = () => { if (writeReport) writeReport(report); };
102
+ let stage = "check";
103
+ try {
104
+ if (stageGuard && !(await stageGuard.start("check"))) throw new Error("RK_CHECK_ERROR: the snapshot is not the commit's bytes plus the ledger before the check stage");
105
+ if (await check("compile") !== 0) throw new Error("class A check (compile tier) failed");
106
+ if (stageGuard && !(await stageGuard.end("check"))) throw new Error("RK_CHECK_ERROR: the check stage's result covers no known bytes");
107
+ report.stages.check = { ok: true }; save();
108
+ out(`rightkit-gate: [check] ${selector} passed`);
109
+
110
+ stage = TOOL_STAGE;
111
+ if (stageGuard && !(await stageGuard.start(TOOL_STAGE))) throw new Error(`RK_CHECK_ERROR: the snapshot is not the commit's bytes plus the ledger before the ${TOOL_STAGE} stage`);
112
+ const doorRun = startRun({ commonDir, commit, tree, stage: TOOL_STAGE });
113
+ let built;
114
+ try {
115
+ const childEnv = { ...env, ...envExtra, ...doorRun.env };
116
+ const cwd = path.join(root, ...plan.cargoDir.split("/"));
117
+ out(`rightkit-gate: [tool] ${selector}@${unit.version}: cargo ${plan.argv.join(" ")} (from ${plan.cargoDir}; executor ${executor?.kind ?? "?"})`);
118
+ const result = await run(plan.argv, { cwd, env: childEnv, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: BUILD_TIMEOUT_MS, windowsHide: true });
119
+ if (result?.error || result?.status !== 0) {
120
+ throw new Error(`${selector}: cargo ${plan.argv.join(" ")} failed (${result?.error?.message ?? `exit ${result?.status}`})\nstdout:\n${String(result?.stdout ?? "").slice(-4000)}\nstderr:\n${String(result?.stderr ?? "").slice(-4000)}`);
121
+ }
122
+ // A carried (broker) call names the receipt's output directory; a direct call wrote the checkout target the gate pinned for this workspace.
123
+ built = builtToolBinary({ outputDir: result.outputDir ?? pinnedTargetDir(targetDirs, plan.cargoDir), executable: plan.executable });
124
+ } finally { endRun(doorRun); }
125
+ if (stageGuard && !(await stageGuard.end(TOOL_STAGE))) throw new Error(`RK_CHECK_ERROR: the ${TOOL_STAGE} stage's result covers no known bytes (a snapshot byte changed, a write is outside the ledger, or a cargo call did not complete)`);
126
+ const builtSha256 = sha256OfFile(built);
127
+ report.stages.tool = { ok: true, binary: built, binarySha256: builtSha256, version: unit.version }; save();
128
+ out(`rightkit-gate: [tool] built ${built} (sha256 ${builtSha256})`);
129
+
130
+ stage = "enrol";
131
+ let enrolled;
132
+ try {
133
+ const root_ = dataRoot ?? resolveManagedRoots({ env }).dataRoot;
134
+ enrolled = plan.enrols === "credential-provider"
135
+ ? enrollProvider({ dataRoot: root_, platform: plan.runnerPlatform, binary: built, version: unit.version })
136
+ : enroll({ dataRoot: root_, platform: plan.runnerPlatform, runner: built, version: unit.version });
137
+ } catch (error) {
138
+ if (error instanceof CredentialProviderError || error?.code?.startsWith?.("RK_CRED_PROVIDER_")) throw new Error(`${error.code}: ${error.message}\nthe built binary is ${built} (sha256 ${builtSha256}); a deliberate replacement of an enrolled version is \`node packages/release/credential-provider.mjs enroll --binary ${built} --version ${unit.version} --platform ${plan.runnerPlatform} --rotate\``);
139
+ if (error instanceof QaRunnerError || error?.code?.startsWith?.("RK_QA_RUNNER_")) throw new Error(`${error.code}: ${error.message}\nthe built binary is ${built} (sha256 ${builtSha256}); a deliberate replacement of an enrolled version is \`right-release qa runner enroll --runner ${built} --version ${unit.version} --platform ${plan.runnerPlatform} --rotate\``);
140
+ throw error;
141
+ }
142
+ // The copy that was recorded must be the very bytes that were built: a binary swapped between the digest and the copy is refused.
143
+ if (enrolled.entry.sha256 !== builtSha256) throw new Error(`RK_TOOL_DIGEST_DRIFT: the enrolled copy ${enrolled.runnerFile} has sha256 ${enrolled.entry.sha256}, the build produced ${builtSha256}; the binary changed while it was enrolled, so it is not trusted (enrol it again from a fresh build)`);
144
+ report.stages.enrol = { ok: true, runner: enrolled.runnerFile, sha256: enrolled.entry.sha256, version: enrolled.entry.version, platform: plan.runnerPlatform, record: enrolled.file, rotated: enrolled.rotated };
145
+ save();
146
+ out(`rightkit-gate: [tool] enrolled ${plan.bin} ${enrolled.entry.version} for ${plan.runnerPlatform}: ${enrolled.runnerFile} sha256 ${enrolled.entry.sha256} (record ${enrolled.file})`);
147
+ if (plan.enrols === "credential-provider") out("rightkit-gate: [tool] updater signing and the admission check resolve this enrolled provider (credential-provider.mjs); PATH is not used on a Mac or Windows host");
148
+ else out(`rightkit-gate: [tool] the owner admits it with: right-release qa policy approve-runner --runner ${enrolled.runnerFile} --version ${enrolled.entry.version} --platform ${plan.runnerPlatform} --set-by <name>`);
149
+ out(`rightkit-gate: [tool] ${selector} passed`);
150
+ return 0;
151
+ } catch (error) {
152
+ report.stages[stage] = { ok: false }; save();
153
+ err(`rightkit-gate: tool ${selector} stopped at ${stage}: ${error.message}`);
154
+ return 1;
155
+ }
156
+ }
package/unit-gate.mjs CHANGED
@@ -69,7 +69,12 @@ export function writeQualification({ report, artifact, unit, commit, tree, runti
69
69
  if (!/^[0-9a-f]{64}$/.test(artifact.contentSha256 ?? "")) throw new Error("release needs the qualified npm archive's content digest");
70
70
  content.contentSha256 = artifact.contentSha256;
71
71
  const excluded = exclusionsFor(unit.name);
72
- if (excluded.length) {
72
+ if (excluded.length && executor?.kind === "direct") {
73
+ // A Linux direct origin neither builds nor signs the swap helper (publish.yml's build-swap/sign-swap do, hosted): its archive holds neither
74
+ // entry, and says so. The Mac rule below is unchanged.
75
+ if (!Array.isArray(artifact.contentExcluded) || artifact.contentExcluded.length) throw new Error(`a direct origin's ${unit.name} archive must hold neither ${excluded.join(" nor ")}`);
76
+ content.contentExcluded = [];
77
+ } else if (excluded.length) {
73
78
  if (!Array.isArray(artifact.contentExcluded) || [...artifact.contentExcluded].sort().join("\n") !== [...excluded].sort().join("\n")) throw new Error(`release needs the qualified ${unit.name} archive to hold ${excluded.join(" and ")}`);
74
79
  content.contentExcluded = [...artifact.contentExcluded].sort();
75
80
  }
@@ -348,6 +353,8 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
348
353
  }
349
354
  // GB-29: a Linux origin has no managed volume; its scratch is the gate's own state directory (XDG state), never a Mac path.
350
355
  const scratch = platform === "win32" ? "D:\\scratch" : platform === "linux" ? path.join(env.XDG_STATE_HOME || path.join(env.HOME || "/tmp", ".local", "state"), "rightkit", "scratch") : "/Volumes/D/scratch";
356
+ // The Linux scratch is a gate-owned state directory that may not exist yet; the bundle itself is still created fresh (non-recursive) by the publisher.
357
+ if (platform === "linux") fs.mkdirSync(scratch, { recursive: true, mode: 0o700 });
351
358
  const bundle = path.join(scratch, `rk-unit-${randomBytes(12).toString("hex")}`);
352
359
  const argv = ["--local", unit.kind === "npm" ? "--package" : "--crate", unit.name, "--workspace", root, "--qualify-only", "--bundle", bundle];
353
360
  const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
@@ -428,7 +435,7 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
428
435
  // GB-29: the direct-origin qualification is written and verified exactly as the hosted side will. Dispatching it needs the publish workflow's `direct` admission input, which is the
429
436
  // reserved publication authorization (GC-T22): nothing is dispatched from a direct host until that input exists.
430
437
  const written = directQualification();
431
- throw new Error(`RK_DIRECT_RELEASE_HELD: the direct-origin qualification ${written} was written and verified; the committed publish workflow admits \`broker\` only, and adding \`direct\` is the reserved publication authorization (GATE-CONTRACT GC-T22, GC-L01), so nothing was dispatched`);
438
+ throw new Error(`RK_DIRECT_RELEASE_HELD: the direct-origin qualification ${written} was written and verified; a direct host has no broker dispatch adapter, so dispatch publish.yml from main with admission=direct and this file as the qualification input (rightkit#1341); nothing was dispatched from here`);
432
439
  }
433
440
  requireBrokerHost(executor); // a direct host has no broker receipts to carry: refuse for that reason, not for the missing receipts
434
441
  const qualification = writeQualification({ report, artifact, unit, commit, tree, runtime, executor });
@@ -0,0 +1,119 @@
1
+ // The Windows package-identity (sparse MSIX) producer: the pure half. An app declares it in its release config as
2
+ //
3
+ // windows: { identity: {
4
+ // manifest: "src-tauri/windows/identity/AppxManifest.xml", // checked-in template; only Identity/@Version is stamped
5
+ // out: "src-tauri/windows/identity/dist", // gets <package> and external-assets/Assets/*; must lie under a declared compile input
6
+ // package: "HeardRight.Identity.msix",
7
+ // version: "0.1.33", // optional; default is the config's `version` (3 or 4 numeric parts)
8
+ // assets: {
9
+ // files: [{ from: "src-tauri/icons/StoreLogo.png", to: "HeardRightStoreLogo.png" }, ...], // into the package AND external-assets/Assets
10
+ // scaled: { svg: "src-tauri/icons/heardright-icon.svg", sizes: [16, 20, 24], name: "HeardRightSquare44x44Logo.targetsize-{size}_altform-unplated.png" },
11
+ // },
12
+ // } }
13
+ //
14
+ // The gate runs it (windows-identity.mjs) inside the door's snapshot before the compile, on Windows only. This module holds no I/O of its own
15
+ // beyond config text matching: validation, version stamping, the step order, the expected package contents, and the write attribution.
16
+
17
+ import path from "node:path";
18
+ import { diffEntries } from "./stage-ledger.mjs";
19
+
20
+ export const WINDOWS_IDENTITY_KEYS = Object.freeze(["manifest", "out", "package", "version", "assets"]);
21
+ export const WINDOWS_IDENTITY_STEPS = Object.freeze(["stage-manifest", "stage-assets", "render-scaled", "makepri-config", "makepri-new", "pack", "unpack-roundtrip", "verify-roundtrip", "verify-pri", "sign", "cleanup"]);
22
+ /** Scratch lives under `out` and is removed before the gate walks it. */
23
+ export const WORK_DIR = ".identity-work";
24
+
25
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+ const posix = (v) => String(v).replaceAll("\\", "/");
27
+ const insideApp = (v) => typeof v === "string" && v.trim() !== "" && !path.isAbsolute(v) && !/^[A-Za-z]:/.test(v) && !posix(v).split("/").includes("..");
28
+ const flatName = (v, ext) => typeof v === "string" && /^[A-Za-z0-9._{}-]+$/.test(v) && v.toLowerCase().endsWith(ext) && !v.startsWith(".");
29
+
30
+ /** Does this config text declare `windows.identity`? Text match like committedWebBuildConfigs: a `.mjs` config is code and is never imported by the gate. */
31
+ export const declaresWindowsIdentity = (text) => /(^|[\s,{])["']?windows["']?\s*:\s*\{[^]*?["']?identity["']?\s*:\s*\{/.test(String(text));
32
+
33
+ /** The release configs of `commit` under `appDir` that declare `windows.identity`, read from `base` (the commit's config decides, never the checkout's). */
34
+ export function committedWindowsIdentityConfigs({ readFile, listConfigs, base }) {
35
+ return listConfigs.filter((f) => { try { return declaresWindowsIdentity(readFile(path.join(base, f))); } catch { return false; } });
36
+ }
37
+
38
+ /** `x.y.z` or `x.y.z.w` numeric (each <= 65535) to the four-part MSIX version; anything else (a prerelease suffix) is refused. */
39
+ export function toMsixVersion(version) {
40
+ const parts = String(version ?? "").split(".");
41
+ if ((parts.length !== 3 && parts.length !== 4) || !parts.every((p) => /^\d{1,5}$/.test(p) && Number(p) <= 65535)) {
42
+ throw Object.assign(new Error(`version "${version}" is not 3 or 4 numeric segments (<=65535); cannot derive an MSIX package version (no prerelease or build suffix)`), { code: "RK_WINDOWS_IDENTITY" });
43
+ }
44
+ return (parts.length === 4 ? parts : [...parts, "0"]).join(".");
45
+ }
46
+
47
+ /** Stamp the real version into the template's `<Identity>` element only; returns `{ xml, name, publisher }`. */
48
+ export function stampManifest(xml, msixVersion) {
49
+ const tag = String(xml).match(/<Identity\b[^>]*\/>/)?.[0];
50
+ if (!tag) throw Object.assign(new Error("the manifest has no <Identity ... /> element"), { code: "RK_WINDOWS_IDENTITY" });
51
+ if (!/\bVersion="[^"]*"/.test(tag)) throw Object.assign(new Error("the manifest <Identity> has no Version attribute"), { code: "RK_WINDOWS_IDENTITY" });
52
+ const name = tag.match(/\bName="([^"]+)"/)?.[1];
53
+ if (!name) throw Object.assign(new Error("the manifest <Identity> has no Name attribute"), { code: "RK_WINDOWS_IDENTITY" });
54
+ return { xml: String(xml).replace(tag, tag.replace(/\bVersion="[^"]*"/, `Version="${msixVersion}"`)), name, publisher: tag.match(/\bPublisher="([^"]*)"/)?.[1] ?? null };
55
+ }
56
+
57
+ /** The declared block, validated. Returns `{ problems, plan }` (plan null with problems). Paths stay app-relative posix. */
58
+ export function planWindowsIdentity(config) {
59
+ const problems = [];
60
+ const block = config?.windows?.identity;
61
+ if (!isObject(block)) return { problems: ["the config declares no windows.identity"], plan: null };
62
+ if (isObject(config.windows)) for (const k of Object.keys(config.windows).filter((k) => k !== "identity")) problems.push(`windows has unknown key ${k} (allowed: identity)`);
63
+ for (const k of Object.keys(block).filter((k) => !WINDOWS_IDENTITY_KEYS.includes(k))) problems.push(`windows.identity has unknown key ${k} (allowed: ${WINDOWS_IDENTITY_KEYS.join(", ")})`);
64
+ if (!insideApp(block.manifest)) problems.push("windows.identity.manifest must be a relative path inside the app");
65
+ if (!insideApp(block.out)) problems.push("windows.identity.out must be a relative path inside the app");
66
+ if (!flatName(block.package, ".msix")) problems.push("windows.identity.package must be a flat .msix file name");
67
+ const version = block.version ?? config?.version;
68
+ let msixVersion = null;
69
+ try { msixVersion = toMsixVersion(version); } catch (e) { problems.push(`windows.identity version: ${e.message}`); }
70
+ const files = block.assets?.files;
71
+ if (!Array.isArray(files) || !files.length) problems.push("windows.identity.assets.files must list at least one { from, to } PNG");
72
+ const seen = new Set();
73
+ for (const [i, f] of (Array.isArray(files) ? files : []).entries()) {
74
+ if (!isObject(f) || !insideApp(f.from) || !flatName(f.to, ".png")) { problems.push(`windows.identity.assets.files[${i}] needs from (app-relative) and to (flat .png name)`); continue; }
75
+ if (seen.has(f.to.toLowerCase())) problems.push(`windows.identity.assets.files[${i}].to ${f.to} repeats`);
76
+ seen.add(f.to.toLowerCase());
77
+ }
78
+ const scaled = block.assets?.scaled;
79
+ if (scaled !== undefined) {
80
+ if (!isObject(scaled) || !insideApp(scaled.svg) || !Array.isArray(scaled.sizes) || !scaled.sizes.length || scaled.sizes.some((s) => !Number.isInteger(s) || s < 1 || s > 1024) || !flatName(scaled.name, ".png") || !String(scaled.name).includes("{size}")) {
81
+ problems.push("windows.identity.assets.scaled needs svg (app-relative), sizes (integers 1..1024) and name (flat .png containing {size})");
82
+ } else for (const s of scaled.sizes) if (seen.has(scaled.name.replace("{size}", s).toLowerCase())) problems.push(`windows.identity.assets.scaled name for size ${s} collides with a files entry`);
83
+ }
84
+ if (problems.length) return { problems, plan: null };
85
+ return {
86
+ problems,
87
+ plan: {
88
+ manifest: posix(block.manifest), out: posix(block.out).replace(/\/+$/, ""), package: block.package, msixVersion,
89
+ files: files.map((f) => ({ from: posix(f.from), to: f.to })),
90
+ scaled: scaled ? { svg: posix(scaled.svg), sizes: [...scaled.sizes], name: scaled.name } : null,
91
+ },
92
+ };
93
+ }
94
+
95
+ /** Every package-relative file the staged tree must contain (and the external Assets must mirror, minus the manifest and PRI). */
96
+ export const scaledNames = (plan) => (plan.scaled ? plan.scaled.sizes.map((s) => plan.scaled.name.replace("{size}", String(s))) : []);
97
+ export const assetNames = (plan) => [...plan.files.map((f) => f.to), ...scaledNames(plan)];
98
+ export const expectedPackageFiles = (plan) => ["AppxManifest.xml", "resources.pri", ...assetNames(plan).map((n) => `Assets/${n}`)];
99
+ /** The PRI resource URIs the manifest-backed (base) assets must resolve to: the taskbar resolves them, presence alone is not enough. */
100
+ export const expectedPriUris = (plan, identityName) => plan.files.map((f) => `ms-resource://${identityName}/Files/Assets/${f.to}`);
101
+
102
+ /** The ordered steps; `scaled` steps are omitted when no scaled renders are declared. */
103
+ export const windowsIdentitySteps = (plan) => WINDOWS_IDENTITY_STEPS.filter((s) => s !== "render-scaled" || plan.scaled);
104
+
105
+ /**
106
+ * Write attribution: everything the producer added or changed lies under its `out` (which must itself lie under a declared compile input),
107
+ * nothing was removed, and the package exists. `declared` and `out` are snapshot-relative posix paths; `out` is the producer's out dir.
108
+ */
109
+ export function identityOutputProblems({ before, after, out, declared, packageFile }) {
110
+ const o = posix(out).replace(/\/+$/, "");
111
+ const roots = declared.map((x) => posix(x).replace(/\/+$/, ""));
112
+ const problems = [];
113
+ if (!roots.some((r) => o === r || o.startsWith(`${r}/`))) problems.push(`windows.identity.out ${o} lies under no declared compile input (declare it in .rightkit/compile-inputs.json)`);
114
+ const d = diffEntries(before, after);
115
+ for (const p of [...d.added, ...d.changed]) if (!(p === o || p.startsWith(`${o}/`))) problems.push(`${p} was written by the Windows identity build but lies outside its out ${o}`);
116
+ for (const p of d.removed) if (!(p === o || p.startsWith(`${o}/`))) problems.push(`${p} was removed by the Windows identity build but lies outside its out ${o}`);
117
+ if (packageFile && !after.some((e) => e.path === `${o}/${packageFile}`)) problems.push(`${o}/${packageFile} was not produced`);
118
+ return problems;
119
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // `node windows-identity.mjs --config <file> [--json] [--dry-run]`: builds the app's Windows package-identity (sparse) MSIX from its release
3
+ // config's `windows.identity` (windows-identity-plan.mjs), the producer the app's own build-windows-identity.mjs used to be. Run by the gate
4
+ // inside the door's snapshot before the compile, on Windows only; under any other host it does nothing.
5
+ //
6
+ // Order (the archived script's, kept): stamp the manifest version into a staged copy; stage the asset PNGs into the package AND the external
7
+ // Assets dir (a sparse package resolves its logo pixels from the install location); render the scaled variants from the SVG; makepri builds
8
+ // resources.pri; makeappx packs with /nv (the identity package ships no executable by design); unpack round-trip; the PRI must index the
9
+ // manifest-backed assets; then the package is signed through sign-windows.mjs, the one provisioned Azure Artifact Signing path. This module
10
+ // never reads a credential, never authenticates, writes no signing receipt, and removes its scratch so only the package and external-assets remain.
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import path from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { spawnSync } from "./background-process.mjs";
16
+ import { loadAppConfig } from "./command-spec.mjs";
17
+ import { findWindowsSdkTool } from "./preflight.mjs";
18
+ import { WORK_DIR, expectedPackageFiles, expectedPriUris, planWindowsIdentity, scaledNames, stampManifest, windowsIdentitySteps } from "./windows-identity-plan.mjs";
19
+ import { webBuildDoorRefusal } from "./web-build.mjs";
20
+
21
+ const SIGN_WINDOWS = path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs");
22
+ const fail = (message) => { throw Object.assign(new Error(message), { code: "RK_WINDOWS_IDENTITY" }); };
23
+
24
+ /** The default SVG renderer: the app's own `@resvg/resvg-js` (resolved from the app root, never RightKit's). Returns a PNG Buffer. */
25
+ export function appResvgRenderer(appRoot) {
26
+ const { Resvg } = createRequire(path.join(appRoot, "package.json"))("@resvg/resvg-js");
27
+ return (svg, size) => new Resvg(svg, { background: "rgba(0,0,0,0)", fitTo: { mode: "width", value: size } }).render().asPng();
28
+ }
29
+
30
+ /**
31
+ * Build the package. `deps` are injectable: `platform`, `run(cmd, args)`, `findTool`, `env`, `render(svg, size)`, `sign(file)`, `log`.
32
+ * Returns `{ skipped, steps, out, package, files }` (`files` app-relative posix). Skips (no writes) when the package already exists.
33
+ */
34
+ export function buildWindowsIdentity({ plan, appRoot }, deps = {}) {
35
+ const { platform = process.platform, run = spawnSync, findTool = findWindowsSdkTool, env = process.env, render = null, log = (m) => console.error(`[windows-identity] ${m}`) } = deps;
36
+ const at = (p) => path.resolve(appRoot, p);
37
+ const outDir = at(plan.out);
38
+ const msix = path.join(outDir, plan.package);
39
+ const steps = windowsIdentitySteps(plan);
40
+ if (platform !== "win32") return { skipped: "not-windows", steps, out: plan.out, package: plan.package, files: [] };
41
+ if (existsSync(msix)) return { skipped: "present", steps, out: plan.out, package: plan.package, files: [] };
42
+ const tool = (name, envName) => { const p = env[envName] || findTool(name)?.toolPath; if (!p || !existsSync(p)) fail(`${name} not found; install the Windows SDK or set ${envName}`); return p; };
43
+ const makeappx = tool("makeappx.exe", "MAKEAPPX_PATH");
44
+ const makepri = tool("makepri.exe", "MAKEPRI_PATH");
45
+ const checked = (cmd, args, what) => { const r = run(cmd, args, { encoding: "utf8", windowsHide: true, timeout: 600000 }); if (r?.error || r?.status !== 0) fail(`${what} failed (${r?.error?.message ?? `exit ${r?.status}`}${r?.stderr ? `: ${String(r.stderr).trim()}` : ""})`); return r; };
46
+ const work = path.join(outDir, WORK_DIR);
47
+ const stage = path.join(work, "stage");
48
+ const external = path.join(outDir, "external-assets", "Assets");
49
+ try {
50
+ rmSync(work, { recursive: true, force: true });
51
+ mkdirSync(path.join(stage, "Assets"), { recursive: true });
52
+ rmSync(path.join(outDir, "external-assets"), { recursive: true, force: true });
53
+ mkdirSync(external, { recursive: true });
54
+ const manifestSource = at(plan.manifest);
55
+ if (!existsSync(manifestSource)) fail(`missing manifest ${plan.manifest}`);
56
+ const stamped = stampManifest(readFileSync(manifestSource, "utf8"), plan.msixVersion);
57
+ writeFileSync(path.join(stage, "AppxManifest.xml"), stamped.xml, "utf8");
58
+ log(`staged manifest ${stamped.name} Version="${plan.msixVersion}"`);
59
+ for (const f of plan.files) {
60
+ if (!existsSync(at(f.from))) fail(`missing asset ${f.from}`);
61
+ copyFileSync(at(f.from), path.join(stage, "Assets", f.to));
62
+ copyFileSync(at(f.from), path.join(external, f.to));
63
+ }
64
+ if (plan.scaled) {
65
+ if (!existsSync(at(plan.scaled.svg))) fail(`missing svg ${plan.scaled.svg}`);
66
+ const svg = readFileSync(at(plan.scaled.svg), "utf8");
67
+ const renderer = render ?? appResvgRenderer(appRoot);
68
+ for (const size of plan.scaled.sizes) {
69
+ const png = renderer(svg, size);
70
+ const name = plan.scaled.name.replace("{size}", String(size));
71
+ writeFileSync(path.join(stage, "Assets", name), png);
72
+ writeFileSync(path.join(external, name), png);
73
+ }
74
+ }
75
+ const priConfig = path.join(work, "priconfig.xml");
76
+ checked(makepri, ["createconfig", "/cf", priConfig, "/dq", "en-US", "/o"], "makepri createconfig");
77
+ checked(makepri, ["new", "/pr", stage, "/cf", priConfig, "/of", path.join(stage, "resources.pri"), "/o"], "makepri new");
78
+ if (!existsSync(path.join(stage, "resources.pri"))) fail("resources.pri was not produced; the taskbar icon would be blank");
79
+ rmSync(msix, { force: true });
80
+ checked(makeappx, ["pack", "/o", "/d", stage, "/nv", "/p", msix], "makeappx pack");
81
+ if (!existsSync(msix) || statSync(msix).size === 0) fail(`makeappx produced no package: ${msix}`);
82
+ const roundTrip = path.join(work, "roundtrip");
83
+ checked(makeappx, ["unpack", "/p", msix, "/d", roundTrip, "/o", "/nv"], "makeappx unpack round-trip");
84
+ const missing = expectedPackageFiles(plan).filter((rel) => !existsSync(path.join(roundTrip, ...rel.split("/"))));
85
+ if (missing.length) fail(`round-trip unpack of ${plan.package} is missing: ${missing.join(", ")}`);
86
+ const externalMissing = [...plan.files.map((f) => f.to), ...scaledNames(plan)].filter((n) => !existsSync(path.join(external, n)));
87
+ if (externalMissing.length) fail(`external Assets lacks files the PRI indexes: ${externalMissing.join(", ")}`);
88
+ const dump = path.join(work, "pri-dump.xml");
89
+ checked(makepri, ["dump", "/if", path.join(roundTrip, "resources.pri"), "/of", dump, "/o"], "makepri dump");
90
+ const dumpXml = readFileSync(dump, "utf8");
91
+ const unindexed = expectedPriUris(plan, stamped.name).filter((uri) => !dumpXml.includes(uri));
92
+ if (unindexed.length) fail(`packaged resources.pri does not index: ${unindexed.join(", ")}`);
93
+ // The one provisioned signer; it verifies after signing and exits non-zero on failure. No receipt file: nothing is written but the package.
94
+ (deps.sign ?? ((file) => checked(process.execPath, [SIGN_WINDOWS, file], "sign-windows")))(msix);
95
+ log(`signed ${plan.package}`);
96
+ } catch (error) {
97
+ rmSync(msix, { force: true }); // never leave an unchecked or unsigned package that a later run would take as present
98
+ throw error;
99
+ } finally { rmSync(work, { recursive: true, force: true }); }
100
+ const files = [`${plan.out}/${plan.package}`, ...[...plan.files.map((f) => f.to), ...scaledNames(plan)].map((n) => `${plan.out}/external-assets/Assets/${n}`)];
101
+ return { skipped: null, steps, out: plan.out, package: plan.package, files };
102
+ }
103
+
104
+ export function parseArgs(argv) {
105
+ const opts = { config: null, json: false, dryRun: false };
106
+ for (let i = 0; i < argv.length; i += 1) {
107
+ if (argv[i] === "--config") { opts.config = argv[i + 1]; i += 1; if (!opts.config || opts.config.startsWith("--")) throw new Error("--config needs a value"); }
108
+ else if (argv[i] === "--json") opts.json = true;
109
+ else if (argv[i] === "--dry-run") opts.dryRun = true;
110
+ else throw new Error(`unknown windows-identity argument: ${argv[i]}`);
111
+ }
112
+ if (!opts.config) throw new Error("--config <file> is required");
113
+ return opts;
114
+ }
115
+
116
+ export async function main(argv = process.argv.slice(2), { env = process.env, platform = process.platform, spawnSyncFn = spawnSync, loadConfig = loadAppConfig, requireDoor, deps = {}, out = (s) => console.log(s) } = {}) {
117
+ const opts = parseArgs(argv);
118
+ const appRoot = path.dirname(path.resolve(opts.config));
119
+ // The door first (a .mjs config is code), then the platform: a non-Windows host builds nothing and says so.
120
+ const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, ...(requireDoor ? { requireDoor } : {}) });
121
+ if (refusal) fail(refusal.replace("right-release web-build", "windows-identity"));
122
+ const result = (r) => { if (opts.json) out(JSON.stringify(r)); return 0; };
123
+ if (platform !== "win32") return result({ skipped: "not-windows", files: [] });
124
+ const { config } = await loadConfig(opts.config);
125
+ const { problems, plan } = planWindowsIdentity(config);
126
+ if (!plan) fail(problems.join("; "));
127
+ if (opts.dryRun) return result({ skipped: "dry-run", steps: windowsIdentitySteps(plan), out: plan.out, package: plan.package, files: [] });
128
+ return result(buildWindowsIdentity({ plan, appRoot }, { platform, env, ...deps }));
129
+ }
130
+
131
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
132
+ main().then((code) => { process.exitCode = code; }, (error) => {
133
+ console.error(`windows-identity: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
134
+ process.exitCode = 1;
135
+ });
136
+ }