@rightkit/release 0.6.10 → 0.6.12
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/README.md +7 -5
- package/admission-files.mjs +3 -0
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +13 -9
- package/bin/rightkit-gate.mjs +772 -355
- package/broker-pins.mjs +152 -0
- package/build-release.mjs +80 -27
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +98 -26
- package/cli/right-release.mjs +28 -4
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +196 -21
- package/development.mjs +13 -4
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +10 -0
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-stages.mjs +76 -10
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/inventory-check.mjs +11 -8
- package/ios-upload.mjs +20 -15
- package/local-publish.mjs +77 -41
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/mirror-root-artifact.mjs +18 -5
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +35 -6
- package/package.json +1 -1
- package/pipeline-admission.mjs +4 -4
- package/preflight.mjs +27 -4
- package/publish-cargo.mjs +4 -3
- package/recipe-build.mjs +38 -14
- package/release-artifact-path.mjs +341 -0
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +44 -17
- package/rightkit-versions.json +63 -42
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +12 -6
- package/snapshot-request.mjs +207 -5
- package/snapshot.mjs +123 -10
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +2 -2
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +100 -20
- package/swap-helper-bundle.mjs +5 -2
- package/unit-gate.mjs +45 -3
- package/web-build.mjs +6 -4
- package/win-package-recipe.mjs +20 -7
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// One resolver for every declared artifact path (item 7 row 7.04c; signed/item7-sage-decisions-3.md R-T1 to R-T5; docs/GATE-CONTRACT.md
|
|
2
|
+
// "Commit-only build" and "Output directory & cache").
|
|
3
|
+
//
|
|
4
|
+
// A release config names its outputs relative to the app: `src-tauri/target/release/bundle/...`, a root `App.dmg`. Checkout mode reaches
|
|
5
|
+
// them through the app root (and the managed artifact directory), exactly as it always did. A door split has no `target/` in its
|
|
6
|
+
// snapshot (a link into the editable checkout is refused, GC:24, and a copy is the rightkit#924 bridge), so the same declared path must
|
|
7
|
+
// mean the Cargo target the gate bound for that manifest. This module is the one place that decides which:
|
|
8
|
+
//
|
|
9
|
+
// checkout today's `resolveReleaseArtifactPath`, byte for byte: `<app root>/<declared>` when it exists, else
|
|
10
|
+
// `RIGHT_RELEASE_ARTIFACT_DIR/<basename>` when that exists, else `<app root>/<declared>`.
|
|
11
|
+
// split R-T2:
|
|
12
|
+
// 1. A relative path starting with a target prefix maps to `<bound target>/<rest>`. No fallback to the snapshot, the checkout or the artifact directory.
|
|
13
|
+
// 2. An absolute path is accepted only inside a bound target or inside RIGHT_RELEASE_ARTIFACT_DIR (realpath; case-folded on Windows).
|
|
14
|
+
// Anything else is RK_SPLIT_TARGET_PATH, a path into the editable checkout other than its bound target included.
|
|
15
|
+
// 3. A relative path with a `..` segment is refused.
|
|
16
|
+
// 4. Any other relative path resolves to `<snapshot app root>/P` when it exists, then `RIGHT_RELEASE_ARTIFACT_DIR/<basename>` when
|
|
17
|
+
// that exists, else `<snapshot app root>/P`. It never resolves to the checkout app root: a stale DMG left in the editable checkout
|
|
18
|
+
// must never be sealed.
|
|
19
|
+
// 5. Outputs written under rule 4 into the snapshot are Q1 `generated` ledger entries (stage-ledger.mjs); the door refuses a
|
|
20
|
+
// non-ignored one at the end of the stage.
|
|
21
|
+
//
|
|
22
|
+
// The bound target of a Cargo manifest is `resolveTargetRoot(<snapshot manifest>)` (cargo-target.mjs, the published resolver), resolved
|
|
23
|
+
// once. On Linux it equals the absolute CARGO_TARGET_DIR the gate resolved in the checkout and passed (row 7.09); on Mac and Dell it is the
|
|
24
|
+
// snapshot carrier's answer. A manifest whose target cannot be resolved is RK_SPLIT_TARGET_UNRESOLVED: there is never a snapshot-relative
|
|
25
|
+
// `target/` fallback (R7). The door cross-checks the recorded targets against its own resolution (RK_SPLIT_TARGET_DRIFT).
|
|
26
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
|
|
30
|
+
import { expandRecipeBuildCommands } from "./recipe-build.mjs";
|
|
31
|
+
import { SourceSplitError, readSourceSplit } from "./source-split.mjs";
|
|
32
|
+
|
|
33
|
+
export const SPLIT_TARGET_PATH_CODE = "RK_SPLIT_TARGET_PATH";
|
|
34
|
+
export const SPLIT_TARGET_UNRESOLVED_CODE = "RK_SPLIT_TARGET_UNRESOLVED";
|
|
35
|
+
export const SPLIT_TARGET_DRIFT_CODE = "RK_SPLIT_TARGET_DRIFT";
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MANIFEST = "src-tauri/Cargo.toml";
|
|
38
|
+
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
|
|
40
|
+
/** A declared artifact path or bound target a split refuses. `code` is one of the three codes above. */
|
|
41
|
+
export class ArtifactPathError extends Error {
|
|
42
|
+
constructor(code, message, problems = []) { super(`${code}: ${message}`); this.name = "ArtifactPathError"; this.code = code; this.problems = problems; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const posix = (value) => String(value).replaceAll("\\", "/");
|
|
46
|
+
const folded = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
|
47
|
+
const isAbsoluteDeclared = (value) => path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}/.test(value);
|
|
48
|
+
|
|
49
|
+
/** The real path of the deepest existing ancestor of `file`, with the missing tail appended: comparable before the file exists. */
|
|
50
|
+
function canonical(file) {
|
|
51
|
+
const absolute = path.resolve(file);
|
|
52
|
+
let head = absolute;
|
|
53
|
+
const tail = [];
|
|
54
|
+
for (;;) {
|
|
55
|
+
try { return folded(path.join(realpathSync.native(head), ...tail.reverse())); } catch { /* not there yet */ }
|
|
56
|
+
const parent = path.dirname(head);
|
|
57
|
+
if (parent === head) return folded(absolute);
|
|
58
|
+
tail.push(path.basename(head));
|
|
59
|
+
head = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const insideCanonical = (child, parent) => {
|
|
64
|
+
const relative = path.relative(parent, child);
|
|
65
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** True when this process is a child of a door run (`RK_GATE_CHECKOUT` names the checkout): artifacts resolve through the split. */
|
|
69
|
+
export const isSplitArtifactEnv = (env = process.env) => typeof env?.RK_GATE_CHECKOUT === "string" && env.RK_GATE_CHECKOUT !== "";
|
|
70
|
+
|
|
71
|
+
/** `<dir>/` form, posix, relative: the prefix a declared path starts with. */
|
|
72
|
+
function prefixOf(appRoot, dir) {
|
|
73
|
+
const relative = posix(path.relative(appRoot, dir)).replace(/\/+$/, "");
|
|
74
|
+
return relative && relative !== "." && !relative.startsWith("..") ? `${relative}/` : "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every Cargo manifest the app declares, with the target prefix its declared paths use (R-T1): the native layout's prefixes, each recipe
|
|
79
|
+
* `cargoTargetRoot` manifest, and the default `<cargoDir>/target/`. `[{ prefix, manifest }]`, longest prefix first, one row per prefix.
|
|
80
|
+
*/
|
|
81
|
+
export function declaredTargetManifests({ appRoot, nativeLayout = null, target = null, exists = existsSync } = {}) {
|
|
82
|
+
const rows = [];
|
|
83
|
+
const add = (prefix, manifest) => { if (prefix && !rows.some((row) => row.prefix === prefix)) rows.push({ prefix, manifest: path.resolve(manifest) }); };
|
|
84
|
+
if (nativeLayout?.manifestPath) for (const prefix of nativeLayout.targetPrefixes ?? [nativeLayout.targetPrefix]) add(posix(prefix ?? "").replace(/\/*$/, "/").replace(/^\/$/, ""), nativeLayout.manifestPath);
|
|
85
|
+
// A declarative recipe: `cargoTargetRoot: true` is `<tauriCwd>/src-tauri/Cargo.toml`, a string is an app-relative Cargo.toml.
|
|
86
|
+
const targets = Array.isArray(target) ? target : [target];
|
|
87
|
+
for (const spec of targets.flatMap((entry) => [entry?.winPackage, entry?.macPackage])) {
|
|
88
|
+
if (!spec || typeof spec !== "object") continue;
|
|
89
|
+
const explicit = spec.cargoTargetRoot;
|
|
90
|
+
if (explicit !== true && typeof explicit !== "string") continue;
|
|
91
|
+
const cwd = typeof spec.tauriCwd === "string" && spec.tauriCwd !== "." ? posix(spec.tauriCwd) : "";
|
|
92
|
+
const manifestRel = typeof explicit === "string" ? posix(explicit) : path.posix.join(cwd, DEFAULT_MANIFEST);
|
|
93
|
+
const manifest = path.resolve(appRoot, manifestRel);
|
|
94
|
+
add(prefixOf(appRoot, path.join(path.dirname(manifest), "target")), manifest);
|
|
95
|
+
}
|
|
96
|
+
// The historical default: an app with `src-tauri/Cargo.toml` and no declared layout still addresses `src-tauri/target/`.
|
|
97
|
+
const fallback = path.resolve(appRoot, DEFAULT_MANIFEST);
|
|
98
|
+
if (!rows.length && exists(fallback)) add("src-tauri/target/", fallback);
|
|
99
|
+
return rows.sort((left, right) => right.prefix.length - left.prefix.length);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The artifact-path context of this process.
|
|
104
|
+
* `{ mode: "checkout", appRoot, artifactDir, env }` (no door run: nothing changes)
|
|
105
|
+
* `{ mode: "split", snapshotAppRoot, boundTargets: [{ prefix, manifest, dir }], artifactDir, ... }`
|
|
106
|
+
* `cwd` is the app root (the snapshot's in a split). `nativeLayout` is resolveNativeCargoLayout's answer (null for a Cargo-less app), `target`
|
|
107
|
+
* the UNEXPANDED `config.targets[platform]` (the recipe keys are gone after expansion; a list of targets binds each). `resolve: false` skips the Cargo calls (the prefixes
|
|
108
|
+
* alone, for the command scan). `resolveTargetRoot`, `exists` and `snapshotRoot` are injectable.
|
|
109
|
+
* Throws ArtifactPathError RK_SPLIT_TARGET_UNRESOLVED when a manifest's target cannot be resolved or resolves inside the snapshot, and
|
|
110
|
+
* RK_SPLIT_TARGET_DRIFT when it differs from the absolute CARGO_TARGET_DIR the gate handed this process.
|
|
111
|
+
*/
|
|
112
|
+
export function splitArtifactContext({
|
|
113
|
+
cwd, env = process.env, nativeLayout = null, config = {}, target = null, resolveTargetRoot = cargoResolveTargetRoot,
|
|
114
|
+
exists = existsSync, snapshotRoot, resolve = true,
|
|
115
|
+
} = {}) {
|
|
116
|
+
void config;
|
|
117
|
+
if (!cwd) throw new TypeError("splitArtifactContext requires cwd (the app root)");
|
|
118
|
+
const appRoot = path.resolve(cwd);
|
|
119
|
+
const artifactDir = env.RIGHT_RELEASE_ARTIFACT_DIR ? path.resolve(env.RIGHT_RELEASE_ARTIFACT_DIR) : null;
|
|
120
|
+
if (!isSplitArtifactEnv(env)) return Object.freeze({ mode: "checkout", appRoot, snapshotAppRoot: null, boundTargets: Object.freeze([]), prefixes: Object.freeze([]), artifactDir, env, exists });
|
|
121
|
+
const declared = declaredTargetManifests({ appRoot, nativeLayout, target, exists });
|
|
122
|
+
let root = snapshotRoot ? path.resolve(snapshotRoot) : null;
|
|
123
|
+
if (!root) { try { root = readSourceSplit({ env, cwd: appRoot })?.snapshot ?? null; } catch (error) { if (!(error instanceof SourceSplitError)) throw error; } }
|
|
124
|
+
const snapshot = canonical(root ?? appRoot);
|
|
125
|
+
const pinned = typeof env.CARGO_TARGET_DIR === "string" && path.isAbsolute(env.CARGO_TARGET_DIR) ? canonical(env.CARGO_TARGET_DIR) : null;
|
|
126
|
+
const cache = new Map();
|
|
127
|
+
const boundTargets = declared.map(({ prefix, manifest }) => {
|
|
128
|
+
if (!resolve) return Object.freeze({ prefix, manifest, dir: null });
|
|
129
|
+
if (!cache.has(manifest)) cache.set(manifest, bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot, exists }));
|
|
130
|
+
return Object.freeze({ prefix, manifest, dir: cache.get(manifest) });
|
|
131
|
+
});
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
mode: "split", appRoot, snapshotAppRoot: appRoot, snapshotRoot: root ?? appRoot, boundTargets: Object.freeze(boundTargets),
|
|
134
|
+
prefixes: Object.freeze(declared.map((row) => row.prefix)), artifactDir, env, exists,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot, exists }) {
|
|
139
|
+
if (!exists(manifest)) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `the Cargo manifest ${manifest} is not in the snapshot, so its target directory cannot be bound; a split never falls back to a snapshot-relative target/`);
|
|
140
|
+
let dir;
|
|
141
|
+
try { dir = resolveTargetRoot(manifest); } catch (error) {
|
|
142
|
+
throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `cargo metadata for ${manifest} reported no target directory (${error.message}); a split never falls back to a snapshot-relative target/ (the snapshot carrier must serve cargo metadata for a split child)`);
|
|
143
|
+
}
|
|
144
|
+
if (typeof dir !== "string" || !path.isAbsolute(dir)) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `cargo metadata for ${manifest} did not report an absolute target directory`);
|
|
145
|
+
const real = canonical(dir);
|
|
146
|
+
if (insideCanonical(real, snapshot) || insideCanonical(real, canonical(appRoot))) {
|
|
147
|
+
throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `the Cargo target of ${manifest} resolves inside the snapshot (${dir}); the gate binds a split's target outside it (CARGO_TARGET_DIR, row 7.09) and never to a snapshot-relative target/`);
|
|
148
|
+
}
|
|
149
|
+
if (pinned && real !== pinned) throw new ArtifactPathError(SPLIT_TARGET_DRIFT_CODE, `the Cargo target of ${manifest} is ${dir}, not the CARGO_TARGET_DIR the gate bound for this stage`);
|
|
150
|
+
return path.resolve(dir);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The context for a RightKit subcommand that gets only its argv and cwd (create-mac-updater, sign-updater, mirror-root-artifact, win-package-recipe):
|
|
155
|
+
* it reads the release config in `cwd`, so the target prefixes are the app's own, for every platform's target. Checkout mode needs no config.
|
|
156
|
+
*/
|
|
157
|
+
export async function loadSplitArtifactContext({ cwd = process.cwd(), env = process.env, configName, ...inject } = {}) {
|
|
158
|
+
if (!isSplitArtifactEnv(env)) return splitArtifactContext({ cwd, env, ...inject });
|
|
159
|
+
const { defaultReleaseConfigFile, importReleaseConfig } = await import("./release-config.mjs");
|
|
160
|
+
const { resolveNativeCargoLayout, nativeCargoDeclared } = await import("./native-cargo-layout.mjs");
|
|
161
|
+
const appRoot = path.resolve(cwd);
|
|
162
|
+
let config = {};
|
|
163
|
+
try { config = await importReleaseConfig(path.resolve(appRoot, configName ?? defaultReleaseConfigFile(appRoot)), { lenient: true }); } catch { /* an app with no readable config binds the default manifest only */ }
|
|
164
|
+
let nativeLayout = null;
|
|
165
|
+
try { if (nativeCargoDeclared({ appRoot, config })) nativeLayout = resolveNativeCargoLayout({ appRoot, config }); } catch { nativeLayout = null; }
|
|
166
|
+
return splitArtifactContext({ cwd: appRoot, env, nativeLayout, config, target: Object.values(config?.targets ?? {}), ...inject });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const insideBound = (file, ctx) => ctx.boundTargets.find((bound) => bound.dir && insideCanonical(canonical(file), canonical(bound.dir)));
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The file a declared artifact path means (R-T2). Checkout mode is the historical rule; see the module header for the split rules.
|
|
173
|
+
* Throws ArtifactPathError RK_SPLIT_TARGET_PATH for a split path the rules refuse.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveArtifactPath(declared, ctx, { write = false } = {}) {
|
|
176
|
+
if (typeof declared !== "string" || !declared) throw new TypeError("a declared artifact path is required");
|
|
177
|
+
if (!ctx || (ctx.mode !== "split" && ctx.mode !== "checkout")) throw new TypeError("resolveArtifactPath requires a context from splitArtifactContext");
|
|
178
|
+
if (ctx.mode === "checkout") {
|
|
179
|
+
const local = path.resolve(ctx.appRoot, declared);
|
|
180
|
+
if (ctx.exists(local)) return local;
|
|
181
|
+
const managed = ctx.env.RIGHT_RELEASE_ARTIFACT_DIR;
|
|
182
|
+
if (managed) {
|
|
183
|
+
const candidate = path.join(path.resolve(managed), path.basename(declared));
|
|
184
|
+
if (ctx.exists(candidate)) return candidate;
|
|
185
|
+
}
|
|
186
|
+
return local;
|
|
187
|
+
}
|
|
188
|
+
const normalized = posix(declared);
|
|
189
|
+
if (isAbsoluteDeclared(declared)) {
|
|
190
|
+
const absolute = path.resolve(declared);
|
|
191
|
+
if (insideBound(absolute, ctx)) return absolute;
|
|
192
|
+
if (ctx.artifactDir && insideCanonical(canonical(absolute), canonical(ctx.artifactDir))) return absolute;
|
|
193
|
+
throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${declared} is neither inside a bound Cargo target (${ctx.boundTargets.map((bound) => bound.dir).filter(Boolean).join(", ") || "none"}) nor inside RIGHT_RELEASE_ARTIFACT_DIR; a split reads declared artifacts only there, never from the editable checkout or an unrelated directory`);
|
|
194
|
+
}
|
|
195
|
+
if (normalized.split("/").includes("..")) throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${declared} contains a .. segment; a split resolves a declared artifact path only below the snapshot app root or a bound target`);
|
|
196
|
+
const relative = path.posix.normalize(normalized).replace(/^\.\//, "");
|
|
197
|
+
for (const bound of ctx.boundTargets) {
|
|
198
|
+
const prefix = bound.prefix.replace(/\/+$/, "");
|
|
199
|
+
if (relative === prefix || relative.startsWith(`${prefix}/`)) {
|
|
200
|
+
if (!bound.dir) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `${declared} is below ${prefix}/, whose Cargo target was not resolved`);
|
|
201
|
+
return path.join(bound.dir, relative.slice(prefix.length).replace(/^\/+/, ""));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const local = path.resolve(ctx.snapshotAppRoot, relative);
|
|
205
|
+
// A writer (a file the step is about to create) never lands in the managed artifact directory by accident of a stale copy there.
|
|
206
|
+
if (write) return local;
|
|
207
|
+
if (ctx.exists(local)) {
|
|
208
|
+
// A link out of the snapshot would be a checkout read in disguise.
|
|
209
|
+
if (!insideCanonical(canonical(local), canonical(ctx.snapshotRoot ?? ctx.snapshotAppRoot))) throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${declared} resolves outside the snapshot (${local}); a split never reads the editable checkout`);
|
|
210
|
+
return local;
|
|
211
|
+
}
|
|
212
|
+
if (ctx.artifactDir) {
|
|
213
|
+
const candidate = path.join(ctx.artifactDir, path.basename(relative));
|
|
214
|
+
if (ctx.exists(candidate)) return candidate;
|
|
215
|
+
}
|
|
216
|
+
return local;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Where a step writes a declared output (create-mac-updater's tarball, a DMG): the same refusals and target mapping as `resolveArtifactPath`, but a
|
|
221
|
+
* relative non-target path is always `<snapshot app root>/P`. Checkout mode is `path.resolve(app root, declared)`, as those writers always did.
|
|
222
|
+
*/
|
|
223
|
+
export function resolveArtifactWritePath(declared, ctx) {
|
|
224
|
+
if (ctx?.mode !== "split") return path.resolve(ctx?.appRoot ?? process.cwd(), declared);
|
|
225
|
+
return resolveArtifactPath(declared, ctx, { write: true });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** What the sealed manifest records so the door can cross-check it (`boundTargets`, R-T1): `[{ prefix, manifest, dir }]`, manifest posix-relative to the app. */
|
|
229
|
+
export function boundTargetsEvidence(ctx) {
|
|
230
|
+
if (ctx?.mode !== "split") return [];
|
|
231
|
+
return ctx.boundTargets.map((bound) => ({ prefix: bound.prefix, manifest: posix(path.relative(ctx.snapshotAppRoot, bound.manifest)), dir: bound.dir }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The door's cross-check (R-T1, 7.08): every target the child recorded must be the one the door resolved itself (`expected`, absolute
|
|
236
|
+
* directories: on Linux the gate's 7.09 resolution, on Mac and Dell the verified receipts' `outputDir`s). Returns problems, [] when equal.
|
|
237
|
+
* A recorded list that is missing or empty while the door expected a target is a problem too: the child's value is never trusted alone.
|
|
238
|
+
*/
|
|
239
|
+
export function crossCheckBoundTargets({ recorded, expected }) {
|
|
240
|
+
const want = [...new Set((expected ?? []).filter(Boolean).map((dir) => canonical(dir)))];
|
|
241
|
+
const got = Array.isArray(recorded) ? recorded.map((entry) => (entry?.dir ? canonical(entry.dir) : null)) : [];
|
|
242
|
+
const problems = [];
|
|
243
|
+
if (want.length && !got.length) problems.push(`the sealed manifest records no bound Cargo target, but the gate bound ${want.join(", ")}`);
|
|
244
|
+
for (const [index, dir] of got.entries()) {
|
|
245
|
+
if (!dir) problems.push(`boundTargets[${index}] records no directory`);
|
|
246
|
+
else if (!want.includes(dir)) problems.push(`boundTargets[${index}] is ${recorded[index].dir}, not the directory the gate bound (${want.join(", ") || "none"})`);
|
|
247
|
+
}
|
|
248
|
+
return problems;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- R-T4: no target-prefixed relative path in a command a RightKit entry does not own -------------------------------------------
|
|
252
|
+
|
|
253
|
+
const RIGHTKIT_BINS = new Set(["right-release", "rightkit-qa"]);
|
|
254
|
+
const RIGHTKIT_SCRIPTS = new Set(["recipe-build.mjs", "create-mac-updater.mjs", "sign-updater.mjs", "mirror-root-artifact.mjs", "win-package-recipe.mjs", "mac-package-recipe.mjs", "right-release.mjs"]);
|
|
255
|
+
const binName = (token) => path.posix.basename(posix(token)).replace(/\.(?:cmd|exe|ps1)$/i, "");
|
|
256
|
+
|
|
257
|
+
/** True for `right-release ...`, `rightkit-qa ...`, `pnpm exec right-release ...` and `node <this package's own script> ...`: commands RightKit resolves itself. */
|
|
258
|
+
export function isRightKitEntry(command) {
|
|
259
|
+
const cmd = typeof command === "string" ? command : command?.cmd;
|
|
260
|
+
const args = typeof command === "string" ? [] : (command?.args ?? []).map(String);
|
|
261
|
+
if (typeof cmd !== "string") return false;
|
|
262
|
+
if (RIGHTKIT_BINS.has(binName(cmd))) return true;
|
|
263
|
+
if (["pnpm", "npx"].includes(binName(cmd)) && args[0] === "exec" && RIGHTKIT_BINS.has(binName(args[1] ?? ""))) return true;
|
|
264
|
+
if (["pnpm", "npx"].includes(binName(cmd)) && RIGHTKIT_BINS.has(binName(args[0] ?? ""))) return true;
|
|
265
|
+
if (binName(cmd) === "node" && args[0]) {
|
|
266
|
+
const script = path.resolve(args[0]);
|
|
267
|
+
if (!RIGHTKIT_SCRIPTS.has(path.basename(script))) return false;
|
|
268
|
+
const dir = path.dirname(script);
|
|
269
|
+
return dir === TOOL_ROOT || dir === path.join(TOOL_ROOT, "cli") || /[\\/]@rightkit[\\/]release(?:[\\/]cli)?$/.test(dir);
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
275
|
+
|
|
276
|
+
/** The first target-prefixed relative path in `text` (at token start, or after `=`, a quote, `:`, `,` or whitespace; `\` read as `/`), or null. */
|
|
277
|
+
export function findTargetPrefixedPath(text, prefixes) {
|
|
278
|
+
const value = posix(text);
|
|
279
|
+
for (const prefix of prefixes) {
|
|
280
|
+
const stem = escapeRegExp(prefix.replace(/\/+$/, ""));
|
|
281
|
+
const match = new RegExp(`(?:^|[=:"',\\s])(?:\\./)?(${stem}(?:/[^\\s"',]*)?)(?=$|[\\s"',])`).exec(value);
|
|
282
|
+
if (match) return match[1];
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const COMMAND_KEYS = ["prePackage", "package", "postBuild", "postSign", "preSeal", "publish"];
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Every command step a release runs for `target` (R-T4): prePackage, package, postBuild, postSign, preSeal and publish (each one command or a
|
|
291
|
+
* list), the native package hook, and the prepare / postBuild steps inside a declarative recipe's `recipe-build` plan (the wrapper itself is a
|
|
292
|
+
* RightKit entry; the steps it runs are the app's). `[{ name, cmd, args?, env? }]`. A recipe plan that cannot be read throws: fail closed.
|
|
293
|
+
*/
|
|
294
|
+
export function releaseCommandSteps({ target, config = {}, root = process.cwd() }) {
|
|
295
|
+
const steps = [];
|
|
296
|
+
const add = (name, command) => {
|
|
297
|
+
if (!command || typeof command !== "object") return;
|
|
298
|
+
for (const [index, step] of expandRecipeBuildCommands(command, { root }).entries()) steps.push({ name: index === 0 ? name : `${name} (recipe step ${index})`, ...step });
|
|
299
|
+
};
|
|
300
|
+
for (const key of COMMAND_KEYS) {
|
|
301
|
+
const value = target?.[key];
|
|
302
|
+
(Array.isArray(value) ? value : [value]).forEach((command, index, list) => add(list.length > 1 ? `${key}[${index}]` : key, command));
|
|
303
|
+
}
|
|
304
|
+
add("nativeAssembly.packageHook", config?.nativeAssembly?.packageHook);
|
|
305
|
+
// A declarative recipe still unexpanded (the controller reads the raw config): the steps it will run around the Tauri build.
|
|
306
|
+
for (const recipe of ["winPackage", "macPackage"]) {
|
|
307
|
+
for (const phase of ["prepare", "postBuild"]) {
|
|
308
|
+
const list = target?.[recipe]?.[phase];
|
|
309
|
+
if (Array.isArray(list)) list.forEach((command, index) => add(`${recipe}.${phase}[${index}]`, command));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return steps;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* R-T4: a split's command step must not name a target-prefixed relative path (`src-tauri/target/...`): the snapshot has no such directory, and a
|
|
317
|
+
* non-RightKit command cannot be remapped. `steps` are `{ name?, cmd, args?, env? }` (or a command string); RightKit entries are skipped.
|
|
318
|
+
* Throws ArtifactPathError RK_SPLIT_TARGET_PATH naming each step and the fix, before anything builds. A checkout-mode context checks nothing.
|
|
319
|
+
*/
|
|
320
|
+
export function assertSplitCommandPaths(steps, ctx) {
|
|
321
|
+
if (!ctx || ctx.mode !== "split") return;
|
|
322
|
+
const prefixes = ctx.prefixes ?? ctx.boundTargets.map((bound) => bound.prefix);
|
|
323
|
+
if (!prefixes.length) return;
|
|
324
|
+
const problems = [];
|
|
325
|
+
steps.forEach((step, index) => {
|
|
326
|
+
const command = typeof step === "string" ? { cmd: step } : step;
|
|
327
|
+
if (!command || isRightKitEntry(command)) return;
|
|
328
|
+
const name = command.name ?? `step ${index + 1}`;
|
|
329
|
+
const values = [command.cmd, ...(command.args ?? []), ...Object.values(command.env ?? {})].filter((value) => typeof value === "string");
|
|
330
|
+
for (const value of values) {
|
|
331
|
+
const hit = findTargetPrefixedPath(value, prefixes);
|
|
332
|
+
if (hit) {
|
|
333
|
+
problems.push(`${name} (${command.cmd}) names the target-relative path ${hit}`);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
if (problems.length) {
|
|
339
|
+
throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${problems.join("; ")}. A door split has no target directory in its snapshot, and only RightKit's own steps are remapped. Pass the path absolute from resolveTargetRoot (@rightkit/release/cargo-target.mjs), as postright and cutright do; nothing was built`, problems);
|
|
340
|
+
}
|
|
341
|
+
}
|
package/release-invocation.mjs
CHANGED
|
@@ -17,7 +17,13 @@ function canonical(file) {
|
|
|
17
17
|
return path.normalize(realpathSync.native(file)).toLowerCase();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The release must run from the primary Git worktree. `split` is a door source split (release-source-identity.mjs, row 7.04): the
|
|
22
|
+
* process then builds the selected commit's snapshot, so the checkout's branch, its HEAD and whether it is detached are irrelevant
|
|
23
|
+
* (GATE-CONTRACT.md: the primary checkout's state is not a build input). Only the primary-worktree identity is still required. Without
|
|
24
|
+
* `split` every check is exactly as it was.
|
|
25
|
+
*/
|
|
26
|
+
export function assertPrimaryReleaseCheckout(repoRoot, env = process.env, { split = false } = {}) {
|
|
21
27
|
const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
22
28
|
const gitDir = git(repoRoot, ["rev-parse", "--git-dir"]).trim();
|
|
23
29
|
const configured = spawnSync("git", ["config", "--path", "--get", "core.worktree"], {
|
|
@@ -33,6 +39,7 @@ export function assertPrimaryReleaseCheckout(repoRoot, env = process.env) {
|
|
|
33
39
|
if (canonical(repoRoot) !== canonical(primary)) {
|
|
34
40
|
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
35
41
|
}
|
|
42
|
+
if (split) return;
|
|
36
43
|
const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
|
|
37
44
|
if (
|
|
38
45
|
branch.status !== 0
|
package/release-qa-command.mjs
CHANGED
|
@@ -25,8 +25,7 @@ import path from "node:path";
|
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
import { spawnSync } from "./background-process.mjs";
|
|
27
27
|
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
28
|
-
import {
|
|
29
|
-
import { assertCleanSource } from "./source-gate.mjs";
|
|
28
|
+
import { NO_REPOSITORY_CODE, assertBindsSource, assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, resolveReleaseSource, snapshotSourceIdentity } from "./release-source-identity.mjs";
|
|
30
29
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
31
30
|
import { auditMain } from "./release-qa-audit.mjs";
|
|
32
31
|
import { journeyRequiredItem, QA_RUN_ENV, relativePosix, validateJourneysReceipt } from "./release-qa-hardening.mjs";
|
|
@@ -102,11 +101,18 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
102
101
|
const executionStartedAt = new Date();
|
|
103
102
|
const gitRunner = deps.gitRunner ?? spawnSync;
|
|
104
103
|
const appRoot = path.dirname(configPath);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
// Source identity (release-source-identity.mjs, item 7 row 7.04). Outside a door run the checkout is the source, proven by the primary
|
|
105
|
+
// worktree and a clean status exactly as before. Inside one (RK_GATE_CHECKOUT) the source is the selected commit's objects and its
|
|
106
|
+
// verified snapshot: no HEAD, status or branch of the checkout is read, and the config, QA plan and journeys all come from the snapshot.
|
|
107
|
+
let release;
|
|
108
|
+
try { release = resolveReleaseSource({ cwd: appRoot, env: deps.env ?? process.env, git: gitRunner }); }
|
|
109
|
+
catch (error) { if (error.code === NO_REPOSITORY_CODE) throw new Error("release config must live inside a Git repository"); throw error; }
|
|
110
|
+
const split = isSnapshotSource(release);
|
|
111
|
+
// Outputs, the sealed vault and the QA receipts live in the checkout's `.right-release` either way (GC:31, GC:65).
|
|
112
|
+
const repoRoot = gitRootOf(release);
|
|
113
|
+
assertReleaseSource(release, { commandId: "right-release qa", env: deps.env ?? process.env, git: gitRunner, skipPrimaryCheckout: Boolean(deps.skipPrimaryCheckout) });
|
|
114
|
+
assertInsideSource(release, configPath, "the release config");
|
|
115
|
+
const source = split ? snapshotSourceIdentity(release, gitRunner) : sourceIdentity(repoRoot, gitRunner);
|
|
110
116
|
const config = await importReleaseConfig(configPath, { query: `qa=${source.commit}` });
|
|
111
117
|
if (!config?.app || !config?.version) throw new Error("release config must expose app and version");
|
|
112
118
|
const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
|
|
@@ -121,13 +127,23 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
121
127
|
else {
|
|
122
128
|
if (!releaseId) throw new Error("--release <sealed-id> is required for the installed stage");
|
|
123
129
|
sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platform === "win" ? "windows" : "mac"));
|
|
124
|
-
|
|
130
|
+
// R4: a split binds the sealed release to the selected commit (and its tree), never to the checkout's HEAD.
|
|
131
|
+
if (split) assertBindsSource(release, sealed.manifest, "the sealed release");
|
|
132
|
+
else if (sealed.manifest.commit !== source.commit) throw new Error("sealed release was built from a different commit than the current checkout");
|
|
125
133
|
sourceReceiptDigest = sealed.manifest.qa?.sourceReceiptDigest ?? null;
|
|
126
134
|
if (!sourceReceiptDigest) throw new Error("sealed release records no source QA receipt; it was not built through the QA gate; rebuild after `right-release qa --stage source`");
|
|
127
135
|
artifacts = sealed.manifest.files.map((file) => ({ name: file.name, sha256: file.sha256, sizeBytes: file.sizeBytes }));
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
const key = ensureQaReceiptKey({ env: deps.env ?? process.env, credentialStore: deps.credentialStore, provision: deps.provision ?? provisionKeyOnHost });
|
|
139
|
+
// R5: the QA plan, the journeys manifest and a named journeys receipt are read from the snapshot, and so is a runner the config names.
|
|
140
|
+
const journeysDeclared = config.qa.journeys ?? null;
|
|
141
|
+
assertInsideSource(release, path.resolve(appRoot, config.qa.config), "qa.config");
|
|
142
|
+
if (journeysDeclared) {
|
|
143
|
+
assertInsideSource(release, path.resolve(appRoot, journeysDeclared.manifest ?? "journeys.toml"), "qa.journeys.manifest");
|
|
144
|
+
if (journeysDeclared.receipt) assertInsideSource(release, path.resolve(appRoot, String(journeysDeclared.receipt)), "qa.journeys.receipt");
|
|
145
|
+
}
|
|
146
|
+
if (config.qa.runner && !deps.runnerFile) assertInsideSource(release, path.resolve(appRoot, config.qa.runner), "qa.runner");
|
|
131
147
|
const runnerFile = deps.runnerFile ?? resolveRunner({ qa: config.qa, appRoot });
|
|
132
148
|
const runnerSha = sha256File(runnerFile);
|
|
133
149
|
const journeysConfig = config.qa.journeys ?? null;
|
|
@@ -183,7 +199,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
183
199
|
const manifestFile = path.resolve(appRoot, journeysConfig.manifest ?? "journeys.toml");
|
|
184
200
|
const receiptFile = journeysConfig.receipt ? path.resolve(appRoot, String(journeysConfig.receipt)) : journeysReceiptFromOutput(journeysRun?.stdout);
|
|
185
201
|
const validation = receiptFile
|
|
186
|
-
? validateJourneysReceipt({ runnerFile, runnerSha256: runnerSha, receiptFile, manifestFile, manifestRelPath: relativePosix(repoRoot, manifestFile), platform, spawn: deps.spawnValidator ?? deps.spawnRunner ?? spawnSync })
|
|
202
|
+
? validateJourneysReceipt({ runnerFile, runnerSha256: runnerSha, receiptFile, manifestFile, manifestRelPath: relativePosix(split ? release.snapshot : repoRoot, manifestFile), platform, spawn: deps.spawnValidator ?? deps.spawnRunner ?? spawnSync })
|
|
187
203
|
: { ok: false, problems: ["rightkit-qa journeys run reported no run directory, so there is no journeys receipt to validate"], block: null };
|
|
188
204
|
if (!deps.quiet) for (const problem of validation.problems) console.error(`[qa] journeys: ${problem}`);
|
|
189
205
|
journeys = validation.block;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The mark of a release source minted by release-source-identity.mjs `resolveReleaseSource` (item 7 row 7.04).
|
|
2
|
+
//
|
|
3
|
+
// A leaf with no imports, so that the ownership evidence (an enforcement entrypoint whose import closure is pinned into the policy
|
|
4
|
+
// bundle) can refuse a hand-built source without pulling the door's modules into that closure. Only the resolver marks a source; a
|
|
5
|
+
// plain object with the same fields is not one.
|
|
6
|
+
const minted = new WeakSet();
|
|
7
|
+
|
|
8
|
+
/** Marks `source` as resolved by the identity module and returns it. */
|
|
9
|
+
export function brandReleaseSource(source) {
|
|
10
|
+
minted.add(source);
|
|
11
|
+
return source;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Whether `source` is an object `brandReleaseSource` marked. */
|
|
15
|
+
export function isReleaseSource(source) {
|
|
16
|
+
return typeof source === "object" && source !== null && minted.has(source);
|
|
17
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Release source identity (item 7 row 7.04; signed/item7-sage-decisions-2.md Q2; docs/GATE-CONTRACT.md "Commit-only build").
|
|
2
|
+
//
|
|
3
|
+
// Every right-release command that builds, qualifies or ships a door stage asks one question before it trusts anything it reads: which
|
|
4
|
+
// source is this, and what proves it? There are exactly two answers.
|
|
5
|
+
//
|
|
6
|
+
// checkout RK_GATE_CHECKOUT is not set (hosted CI, no door, or the Mac/Dell route before the cutover). The source is the checkout's
|
|
7
|
+
// HEAD, proven the way it always was: the primary worktree, a branch-attached HEAD and a clean status (R1). Nothing here
|
|
8
|
+
// changes for that route.
|
|
9
|
+
// snapshot RK_GATE_CHECKOUT is set: this process is a child of a live door run (source-split.mjs `readSourceSplit`). The identity is
|
|
10
|
+
// the SELECTED COMMIT's immutable objects plus the gate's start and end verification of the snapshot under held ownership.
|
|
11
|
+
// It is never the checkout's HEAD, status or branch (GC:24 "no dirty-tree condition", GC:156 item 1, GC:182 E1): the
|
|
12
|
+
// checkout may be dirty, ahead, behind or detached.
|
|
13
|
+
//
|
|
14
|
+
// A process that names a split it cannot prove (no live run, an ended run, another checkout, a cwd outside the snapshot) is refused
|
|
15
|
+
// (R2); it never falls back to the checkout checks, because that would build bytes the identity does not describe.
|
|
16
|
+
//
|
|
17
|
+
// `resolveReleaseSource` is the single owner. It returns a frozen record that only it can mint (release-source-brand.mjs), so the
|
|
18
|
+
// evidence, QA and upload code can refuse a hand-built one. The record's fields are frozen by the decision record:
|
|
19
|
+
// { mode: "snapshot", commit, tree, checkout, checkoutAppRoot, snapshot, appRoot, commonDir }
|
|
20
|
+
// { mode: "checkout", commit, tree, repoRoot, appRoot }
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawnSync } from "./background-process.mjs";
|
|
23
|
+
import { resolveSelectedCommit } from "./committed-source.mjs";
|
|
24
|
+
import { canonicalRepositoryIdentity } from "./ownership-approval.mjs";
|
|
25
|
+
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
26
|
+
import { dependencyClosure } from "./release-qa-receipt.mjs";
|
|
27
|
+
import { brandReleaseSource, isReleaseSource } from "./release-source-brand.mjs";
|
|
28
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
29
|
+
import { SOURCE_SPLIT_CODE, SourceSplitError, inSnapshot, readSourceSplit } from "./source-split.mjs";
|
|
30
|
+
|
|
31
|
+
export const RELEASE_SOURCE_IDENTITY_CODE = "RK_SOURCE_IDENTITY";
|
|
32
|
+
export const NO_REPOSITORY_CODE = "RK_SOURCE_NO_REPOSITORY";
|
|
33
|
+
|
|
34
|
+
/** A release source whose identity does not hold (a commit or tree that is not the selected one, a file outside the snapshot). */
|
|
35
|
+
export class ReleaseSourceError extends Error {
|
|
36
|
+
constructor(message, code = RELEASE_SOURCE_IDENTITY_CODE) { super(message); this.name = "ReleaseSourceError"; this.code = code; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What each resolved source was resolved with, so `assertReleaseSource` can prove the same facts again (a run that has ended since is no
|
|
40
|
+
// split any longer) without the caller threading the context through.
|
|
41
|
+
const contexts = new WeakMap();
|
|
42
|
+
|
|
43
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
44
|
+
|
|
45
|
+
function plainGit(git, cwd, args) {
|
|
46
|
+
const result = git("git", args, { cwd, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
|
|
47
|
+
if (result?.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${String(result?.stderr ?? "").trim() || "no output"}`);
|
|
48
|
+
return String(result.stdout);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The source this process runs under. `git` runs git (a spawnSync-shaped function; tests inject a recorder). `readFile` and `alive` are
|
|
53
|
+
* passed to the door-run reader (tests only). Throws SourceSplitError (R2, R3) when a split is named but cannot be proven; throws
|
|
54
|
+
* an Error outside a git repository when no split is named.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveReleaseSource({ cwd = process.cwd(), env = process.env, git = spawnSync, readFile, alive } = {}) {
|
|
57
|
+
const context = Object.freeze({ cwd, env, git, ...(readFile ? { readFile } : {}), ...(alive ? { alive } : {}) });
|
|
58
|
+
// 1. A live door run under the named checkout, the same checkout, and a cwd inside the run's snapshot. Throws when anything is off.
|
|
59
|
+
const split = readSourceSplit({ env, cwd, run: git, ...(readFile ? { readFile } : {}), ...(alive ? { alive } : {}) });
|
|
60
|
+
if (split) {
|
|
61
|
+
// 2. The tree is re-derived from the object database. It is never taken from the run file alone (R3).
|
|
62
|
+
let objects;
|
|
63
|
+
try { objects = resolveSelectedCommit(split.checkout, split.commit, { runner: git }); }
|
|
64
|
+
catch (error) { throw new SourceSplitError(`the door run's commit ${split.commit.slice(0, 12)} cannot be read from the object database of ${split.checkout}: ${error.message}`); }
|
|
65
|
+
if (objects.commit !== split.commit) throw new SourceSplitError(`the object database of ${split.checkout} answers ${objects.commit} for the door run's commit ${split.commit}`);
|
|
66
|
+
if (objects.tree !== split.tree) throw new SourceSplitError(`the door run records tree ${split.tree.slice(0, 12)} for commit ${split.commit.slice(0, 12)}, but the object database says ${objects.tree.slice(0, 12)} (R3)`);
|
|
67
|
+
const source = brandReleaseSource(Object.freeze({
|
|
68
|
+
mode: "snapshot", commit: objects.commit, tree: objects.tree, checkout: split.checkout, checkoutAppRoot: split.checkoutAppRoot,
|
|
69
|
+
snapshot: split.snapshot, appRoot: split.appRoot, commonDir: split.commonDir,
|
|
70
|
+
}));
|
|
71
|
+
contexts.set(source, context);
|
|
72
|
+
return source;
|
|
73
|
+
}
|
|
74
|
+
// The checkout route: HEAD of the repository at cwd, read exactly as each command always read it.
|
|
75
|
+
let repoRoot = "";
|
|
76
|
+
let detail = "";
|
|
77
|
+
try { repoRoot = plainGit(git, cwd, ["rev-parse", "--show-toplevel"]).trim(); } catch (error) { detail = ` (${error.message})`; }
|
|
78
|
+
if (!repoRoot) throw new ReleaseSourceError(`${cwd} is not inside a Git repository${detail}`, NO_REPOSITORY_CODE);
|
|
79
|
+
const commit = plainGit(git, repoRoot, ["rev-parse", "HEAD"]).trim();
|
|
80
|
+
const tree = plainGit(git, repoRoot, ["rev-parse", `${commit}^{tree}`]).trim();
|
|
81
|
+
if (!OBJECT_ID.test(commit) || !OBJECT_ID.test(tree)) throw new Error("the checkout's HEAD is not a commit");
|
|
82
|
+
const source = brandReleaseSource(Object.freeze({ mode: "checkout", commit, tree, repoRoot, appRoot: path.resolve(cwd) }));
|
|
83
|
+
contexts.set(source, context);
|
|
84
|
+
return source;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The record `resolveReleaseSource` minted, or a refusal: a hand-built look-alike never carries an identity. */
|
|
88
|
+
export function requireReleaseSource(source) {
|
|
89
|
+
if (!isReleaseSource(source)) throw new SourceSplitError("the release source was not resolved by resolveReleaseSource; a hand-built source carries no identity");
|
|
90
|
+
return source;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const isSnapshotSource = (source) => requireReleaseSource(source).mode === "snapshot";
|
|
94
|
+
/** The root every git object read and every output, state, lock and sealed directory belongs to: the checkout (`.right-release` lives there). */
|
|
95
|
+
export const gitRootOf = (source) => (requireReleaseSource(source).mode === "snapshot" ? source.checkout : source.repoRoot);
|
|
96
|
+
/** The root every source, config, input and QA file is read from: the snapshot in a split, else the checkout. */
|
|
97
|
+
export const sourceRootOf = (source) => (requireReleaseSource(source).mode === "snapshot" ? source.snapshot : source.repoRoot);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Proves the source's identity holds now, for the command `commandId`.
|
|
101
|
+
* checkout R1, byte for byte as before: the primary worktree (branch-attached HEAD) and a clean status. `skipPrimaryCheckout` and
|
|
102
|
+
* `skipCleanCheck` are the two existing seams (a test double; a phase resume that reads no unrelated source).
|
|
103
|
+
* snapshot The door run is proven again (live, this checkout, this cwd, the tree from the object database), then the checkout must
|
|
104
|
+
* still be the primary worktree. No status, no HEAD and no branch is read: a dirty or moved checkout is irrelevant.
|
|
105
|
+
* Writes into the snapshot are decided by the door's start and end verification, not here.
|
|
106
|
+
* Returns the source.
|
|
107
|
+
*/
|
|
108
|
+
export function assertReleaseSource(source, { commandId = "release", env, git, skipPrimaryCheckout = false, skipCleanCheck = false } = {}) {
|
|
109
|
+
requireReleaseSource(source);
|
|
110
|
+
const context = contexts.get(source);
|
|
111
|
+
const runGit = git ?? context.git;
|
|
112
|
+
const environment = env ?? context.env;
|
|
113
|
+
if (source.mode === "checkout") {
|
|
114
|
+
if (!skipPrimaryCheckout) assertPrimaryReleaseCheckout(source.repoRoot, environment);
|
|
115
|
+
if (!skipCleanCheck) assertCleanSource({ status: plainGit(runGit, source.repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId });
|
|
116
|
+
return source;
|
|
117
|
+
}
|
|
118
|
+
const again = resolveReleaseSource({ ...context, env: environment, git: runGit });
|
|
119
|
+
if (again.mode !== "snapshot") throw new SourceSplitError("the door run's source split is gone; nothing may continue from a snapshot that is no longer proven");
|
|
120
|
+
for (const key of ["commit", "tree", "checkout", "snapshot", "appRoot", "checkoutAppRoot"]) {
|
|
121
|
+
if (again[key] !== source[key]) throw new SourceSplitError(`the door run's source.${key} changed while ${commandId} ran`);
|
|
122
|
+
}
|
|
123
|
+
// The primary worktree identity stays (GC:31); only the branch-attached refusal, which reads HEAD, is skipped (GC:156).
|
|
124
|
+
if (!skipPrimaryCheckout) assertPrimaryReleaseCheckout(source.checkout, environment, { split: true });
|
|
125
|
+
return source;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* R5: in a split, a config, build input, QA plan or test file must resolve inside the snapshot (symlinks resolved). In the checkout
|
|
130
|
+
* route there is no containment to prove and this does nothing.
|
|
131
|
+
*/
|
|
132
|
+
export function assertInsideSource(source, file, label = "a source file") {
|
|
133
|
+
if (!isSnapshotSource(source)) return;
|
|
134
|
+
if (typeof file !== "string" || !file) throw new ReleaseSourceError(`${label} has no path; a split build reads only the snapshot of ${source.commit.slice(0, 12)}`);
|
|
135
|
+
if (!inSnapshot({ snapshot: source.snapshot }, path.resolve(file))) {
|
|
136
|
+
throw new ReleaseSourceError(`${label} ${file} is outside the snapshot ${source.snapshot} of ${source.commit.slice(0, 12)}; a split build reads the commit's snapshot and never the checkout (R5)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* R4 (and R8): a record that claims a source (a sealed manifest, ownership evidence, a resumed build's state) is for the selected
|
|
142
|
+
* commit and, when it names a tree, that commit's tree. Another commit or tree is refused, never reinterpreted.
|
|
143
|
+
*/
|
|
144
|
+
export function assertBindsSource(source, record, label = "the record") {
|
|
145
|
+
requireReleaseSource(source);
|
|
146
|
+
if (!record || typeof record !== "object") throw new ReleaseSourceError(`${label} is missing, so it cannot be bound to commit ${source.commit.slice(0, 12)}`);
|
|
147
|
+
if (record.commit !== source.commit) throw new ReleaseSourceError(`${label} is for commit ${String(record.commit ?? "(none)").slice(0, 12)}, not the selected commit ${source.commit.slice(0, 12)} (R4)`);
|
|
148
|
+
if (record.tree !== undefined && record.tree !== source.tree) throw new ReleaseSourceError(`${label} is for tree ${String(record.tree).slice(0, 12)}, not the selected commit's tree ${source.tree.slice(0, 12)} (R4)`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The `{ commit, tree, repositoryIdentity }` a source QA receipt binds, for a snapshot source: the selected commit and tree, and the
|
|
153
|
+
* repository identity from the checkout's origin. Reads no HEAD. (The checkout route keeps release-qa-receipt.mjs `sourceIdentity`.)
|
|
154
|
+
*/
|
|
155
|
+
export function snapshotSourceIdentity(source, git = spawnSync) {
|
|
156
|
+
if (!isSnapshotSource(source)) throw new ReleaseSourceError("snapshotSourceIdentity is for a door source split");
|
|
157
|
+
const origin = plainGit(git, source.checkout, ["remote", "get-url", "origin"]).trim();
|
|
158
|
+
return { commit: source.commit, tree: source.tree, repositoryIdentity: canonicalRepositoryIdentity(origin) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `{ source, closure }` options `admitSourceQa` / `assertSourceQaAdmission` / `assertInstalledQaAdmission` take so that the receipt they
|
|
163
|
+
* verify is compared with the SELECTED commit (its tree, its repository identity, its lockfile closure read from its objects) instead of the
|
|
164
|
+
* checkout's HEAD. `{}` for the checkout route: those functions then read HEAD exactly as before. Spread it into their options.
|
|
165
|
+
*/
|
|
166
|
+
export function qaAdmissionSourceOptions(source, git = spawnSync) {
|
|
167
|
+
if (!isSnapshotSource(source)) return {};
|
|
168
|
+
return { source: snapshotSourceIdentity(source, git), closure: dependencyClosure(source.checkout, source.commit, git) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { SOURCE_SPLIT_CODE };
|