@rightkit/release 0.2.164 → 0.2.172

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 (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +315 -13
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +141 -22
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +283 -14
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +110 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // right-release sign-dev-artifact: Developer ID sign an EXISTING, already-qualified CI dev artifact in place (Orthic-Labs/rightkit#257).
3
+ // It never builds, notarizes, publishes or installs. Steps: verify the artifact's recorded hashes, sign each manifest file with its fixed
4
+ // codesign identifier (mac-sign-core signMacosEntries, the provisioned Developer ID only), verify strictly, read back Identifier and
5
+ // TeamIdentifier, refresh the payload metadata, and write evidence JSON. An optional declarative `refresh` in the sign manifest
6
+ // (dev-artifact-refresh.mjs) recomputes digests recorded in JSON files after the code they describe is signed and before the bundle
7
+ // that contains those files is signed, so that bundle seals their final bytes; the payload metadata refresh then records everything.
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { spawnSync } from "./background-process.mjs";
12
+ import { MacosDevelopmentSigningError, resolveMacosDevelopmentSigning } from "./development-signing.mjs";
13
+ import { SIGN_MANIFEST_SCHEMA, parseSignManifest, signMacosEntries } from "./mac-sign-core.mjs";
14
+ import { fileEvidence } from "./macos-file-evidence.mjs";
15
+ import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
16
+ import { runMacCodesignCommands } from "./macos-signing-session.mjs";
17
+ import { SignDevArtifactError, assertInsideOnDisk, contained, fail, toPosix } from "./dev-artifact-support.mjs";
18
+ import { applyRefresh, planRefresh, readRefreshPlan } from "./dev-artifact-refresh.mjs";
19
+
20
+ export const DEV_ARTIFACT_PAYLOAD_SCHEMA = 1;
21
+ export const DEV_ARTIFACT_EVIDENCE_SCHEMA = 1;
22
+ export const DEFAULT_PAYLOAD_METADATA = "payload.json";
23
+
24
+ export { SignDevArtifactError };
25
+
26
+ /**
27
+ * Sign manifest for this command: `{ schema: 1, entries: [{ file, identifier, entitlements?, options?, hardenedRuntime? }] }`.
28
+ * `file` is relative to the artifact directory (a loose executable or the installer .app); `identifier` is required on every entry.
29
+ * Entries sign in the given order, so list nested code before the bundle that contains it. Returns absolute-path entries.
30
+ */
31
+ export function readDevArtifactSignManifest(file, artifact) {
32
+ let raw;
33
+ try { raw = JSON.parse(readFileSync(file, "utf8")); } catch { fail("SIGN_DEV_ARTIFACT_MANIFEST_INVALID", `sign manifest is not readable JSON: ${file}`); }
34
+ if (raw?.schema !== SIGN_MANIFEST_SCHEMA || !Array.isArray(raw.entries) || !raw.entries.length) {
35
+ fail("SIGN_DEV_ARTIFACT_MANIFEST_INVALID", `sign manifest must be { schema: ${SIGN_MANIFEST_SCHEMA}, entries: [...] } with at least one entry`);
36
+ }
37
+ const absolute = raw.entries.map((entry, index) => {
38
+ if (!entry || typeof entry !== "object") fail("SIGN_DEV_ARTIFACT_MANIFEST_INVALID", `sign manifest entry ${index} must be an object`);
39
+ if (entry.identifier === undefined) fail("SIGN_DEV_ARTIFACT_IDENTIFIER_REQUIRED", `sign manifest entry ${index} (${entry.file}) needs a fixed identifier`);
40
+ return {
41
+ ...entry,
42
+ file: contained(artifact, entry.file, `sign manifest entry ${index} file`),
43
+ ...(entry.entitlements !== undefined ? { entitlements: contained(artifact, entry.entitlements, `sign manifest entry ${index} entitlements`) } : {}),
44
+ };
45
+ });
46
+ const entries = parseSignManifest(JSON.stringify({ schema: SIGN_MANIFEST_SCHEMA, entries: absolute }));
47
+ const seen = new Set();
48
+ entries.forEach((entry, index) => {
49
+ if (seen.has(entry.file)) fail("SIGN_DEV_ARTIFACT_MANIFEST_INVALID", `sign manifest lists ${entry.file} twice`);
50
+ seen.add(entry.file);
51
+ for (const earlier of entries.slice(0, index)) {
52
+ if (entry.file.startsWith(earlier.file + path.sep)) fail("SIGN_DEV_ARTIFACT_ORDER_INVALID", `${path.relative(artifact, earlier.file)} contains ${path.relative(artifact, entry.file)} but is listed first; sign nested code before its bundle`);
53
+ }
54
+ });
55
+ return entries;
56
+ }
57
+
58
+ /** The optional declarative `refresh` of the sign manifest (see dev-artifact-refresh.mjs): `{ root, operations }`, absolute paths, validated. */
59
+ export function readDevArtifactRefreshPlan(file, artifact) {
60
+ let raw;
61
+ try { raw = JSON.parse(readFileSync(file, "utf8")); } catch { fail("SIGN_DEV_ARTIFACT_MANIFEST_INVALID", `sign manifest is not readable JSON: ${file}`); }
62
+ return readRefreshPlan(raw, artifact);
63
+ }
64
+
65
+ /** Read the artifact's payload metadata `{ schema: 1, files: [{ path, sha256, ... }] }` and verify every recorded hash against disk. */
66
+ export function verifyDevArtifactPayload(artifact, metadataFile) {
67
+ let metadata;
68
+ try { metadata = JSON.parse(readFileSync(metadataFile, "utf8")); } catch { fail("SIGN_DEV_ARTIFACT_METADATA_INVALID", `payload metadata is not readable JSON: ${metadataFile}`); }
69
+ if (metadata?.schema !== DEV_ARTIFACT_PAYLOAD_SCHEMA || !Array.isArray(metadata.files) || !metadata.files.length) {
70
+ fail("SIGN_DEV_ARTIFACT_METADATA_INVALID", `payload metadata must be { schema: ${DEV_ARTIFACT_PAYLOAD_SCHEMA}, files: [...] } with at least one file: ${metadataFile}`);
71
+ }
72
+ const recorded = new Map();
73
+ for (const item of metadata.files) {
74
+ if (!/^[0-9a-f]{64}$/.test(item?.sha256 ?? "")) fail("SIGN_DEV_ARTIFACT_METADATA_INVALID", `payload metadata entry ${JSON.stringify(item?.path)} has no sha256`);
75
+ const absolute = contained(artifact, item.path, "payload metadata path");
76
+ if (!existsSync(absolute)) fail("SIGN_DEV_ARTIFACT_MISSING", `payload file is missing: ${item.path}`);
77
+ const actual = fileEvidence(absolute).sha256;
78
+ if (actual !== item.sha256) fail("SIGN_DEV_ARTIFACT_HASH_MISMATCH", `recorded hash does not match ${item.path}: recorded ${item.sha256}, found ${actual}. Refusing to sign bytes that were not qualified.`);
79
+ recorded.set(absolute, item);
80
+ }
81
+ return { metadata, recorded };
82
+ }
83
+
84
+ const defaultInspect = (run, env) => (command, args) => {
85
+ const result = run(command, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
86
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
87
+ if (result.error || result.status !== 0) fail("SIGN_DEV_ARTIFACT_INSPECT_FAILED", `${command} ${args.join(" ")} failed: ${output.trim() || result.error?.message}`);
88
+ return output;
89
+ };
90
+
91
+ /**
92
+ * Sign an existing dev artifact. Injectable for tests: `resolveSigning`, `signOne`, `inspect`, `evidenceOf`.
93
+ * Returns the evidence object (also written to `evidencePath`). With `dryRun` only the hash verification and manifest parsing run.
94
+ */
95
+ export function signDevArtifact({
96
+ artifact, manifestPath, metadataPath, evidencePath, dryRun = false,
97
+ env = process.env, run = spawnSync, platform = process.platform,
98
+ resolveSigning = (options) => resolveMacosDevelopmentSigning(options),
99
+ signOne = (codesignArgs) => runMacCodesignCommands([codesignArgs], { env, run })[0],
100
+ inspect = defaultInspect(run, env), evidenceOf = fileEvidence,
101
+ } = {}) {
102
+ if (!artifact || !manifestPath) fail("SIGN_DEV_ARTIFACT_USAGE", "usage: right-release sign-dev-artifact --artifact <dir> --manifest <sign-manifest.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]");
103
+ const root = path.resolve(artifact);
104
+ if (!existsSync(root)) fail("SIGN_DEV_ARTIFACT_MISSING", `artifact directory does not exist: ${root}`);
105
+ const metadataFile = path.resolve(metadataPath ?? path.join(root, DEFAULT_PAYLOAD_METADATA));
106
+ const entries = readDevArtifactSignManifest(path.resolve(manifestPath), root);
107
+ const refreshPlan = readDevArtifactRefreshPlan(path.resolve(manifestPath), root);
108
+ const plan = planRefresh(entries, refreshPlan.operations, metadataFile);
109
+ const before = verifyDevArtifactPayload(root, metadataFile);
110
+ for (const entry of entries) {
111
+ if (!existsSync(entry.file)) fail("SIGN_DEV_ARTIFACT_MISSING", `sign manifest file is missing: ${path.relative(root, entry.file)}`);
112
+ if (!before.recorded.has(entry.file)) fail("SIGN_DEV_ARTIFACT_NOT_RECORDED", `${path.relative(root, entry.file)} is not in the payload metadata, so its hash was never recorded`);
113
+ if (entry.entitlements && !existsSync(entry.entitlements)) fail("SIGN_DEV_ARTIFACT_MISSING", `entitlements file is missing: ${path.relative(root, entry.entitlements)}`);
114
+ }
115
+ if (dryRun) return { schema: DEV_ARTIFACT_EVIDENCE_SCHEMA, dryRun: true, files: entries.map((entry) => ({ path: toPosix(path.relative(root, entry.file)), identifier: entry.identifier })) };
116
+ if (platform !== "darwin") fail("SIGN_DEV_ARTIFACT_PLATFORM", "sign-dev-artifact must run on macOS");
117
+ if (!evidencePath) fail("SIGN_DEV_ARTIFACT_USAGE", "--evidence requires a path");
118
+
119
+ // Same Developer ID resolution as dev signing: no ad hoc and no Apple Development fallback.
120
+ const signing = resolveSigning({ root, bundle: root, descriptor: {}, config: {}, env, run });
121
+ if (signing.adhoc || !/^Developer ID Application:/.test(signing.identity ?? "")) {
122
+ throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED", `sign-dev-artifact needs the provisioned Developer ID Application identity; got ${JSON.stringify(signing.identity)}. There is no ad hoc or Apple Development fallback.`, { identity: signing.identity });
123
+ }
124
+ const { identity, teamIdentifier } = signing;
125
+
126
+ const sha256Before = new Map(entries.map((entry) => [entry.file, evidenceOf(entry.file).sha256]));
127
+ // Entries before the refresh sign first, then the declared metadata is recomputed from their final bytes, then the entries that
128
+ // contain the refreshed files (the wrapper bundle) sign over them. Without a refresh this is one signing pass, as before.
129
+ const signingOptions = { identity, signOne, inspect, evidence: evidenceOf, bundle: root };
130
+ // The manifest paths were checked when it was read; check them on disk again right before codesign touches them.
131
+ const recheck = (list) => {
132
+ for (const entry of list) {
133
+ assertInsideOnDisk(root, entry.file, `sign manifest file ${path.relative(root, entry.file)}`);
134
+ if (entry.entitlements) assertInsideOnDisk(root, entry.entitlements, `entitlements ${path.relative(root, entry.entitlements)}`);
135
+ }
136
+ return list;
137
+ };
138
+ const signed = plan.before.length ? signMacosEntries(recheck(plan.before), signingOptions) : [];
139
+ const refreshOps = refreshPlan.operations.length ? applyRefresh(refreshPlan.operations, refreshPlan.root) : [];
140
+ if (plan.after.length) signed.push(...signMacosEntries(recheck(plan.after), signingOptions));
141
+ const files = signed.map((record) => {
142
+ const rel = toPosix(path.relative(root, record.file));
143
+ const details = inspect("codesign", ["-dvv", record.file]);
144
+ const team = /^TeamIdentifier=(.+)$/m.exec(details)?.[1]?.trim();
145
+ const identifier = /^Identifier=(.+)$/m.exec(details)?.[1]?.trim();
146
+ if (!team || team !== teamIdentifier) fail("SIGN_DEV_ARTIFACT_TEAM_MISMATCH", `${rel}: expected TeamIdentifier=${teamIdentifier}, found ${team ?? "missing"}`);
147
+ if (!identifier || identifier !== record.identifier) fail("SIGN_DEV_ARTIFACT_IDENTIFIER_MISMATCH", `${rel}: expected Identifier=${record.identifier}, found ${identifier ?? "missing"}`);
148
+ const requirement = inspect("codesign", ["-d", "-r-", record.file]);
149
+ const designatedRequirement = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
150
+ if (!designatedRequirement || /\bcdhash\b/.test(designatedRequirement)) fail("SIGN_DEV_ARTIFACT_UNSTABLE_REQUIREMENT", `${rel}: designated requirement ${JSON.stringify(designatedRequirement)} is not identity-based, so privacy grants would not survive a rebuild`);
151
+ return { path: rel, sha256Before: sha256Before.get(record.file), sha256After: record.after.sha256, identifier, teamIdentifier: team, designatedRequirement };
152
+ });
153
+
154
+ // Refresh the payload metadata: every recorded hash is recomputed (a signed bundle changes its tree digest too).
155
+ const payloadFiles = before.metadata.files.map((item) => {
156
+ const evidence = evidenceOf(contained(root, item.path, "payload metadata path"));
157
+ return { ...item, sha256: evidence.sha256, ...(item.sizeBytes !== undefined ? { sizeBytes: evidence.sizeBytes } : {}) };
158
+ });
159
+ const metadata = { ...before.metadata, files: payloadFiles, signing: { contract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, teamIdentifier, signedPaths: files.map((file) => file.path) } };
160
+ const metadataBefore = evidenceOf(metadataFile).sha256;
161
+ const staging = `${metadataFile}.signing`;
162
+ rmSync(staging, { force: true }); // a stale file or planted link is removed itself, never written through
163
+ writeFileSync(staging, `${JSON.stringify(metadata, null, 2)}\n`, { flag: "wx" });
164
+ renameSync(staging, metadataFile);
165
+ verifyDevArtifactPayload(root, metadataFile);
166
+
167
+ const result = {
168
+ schema: DEV_ARTIFACT_EVIDENCE_SCHEMA, signingContract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, teamIdentifier, notarized: false, built: false, published: false,
169
+ artifact: root, metadata: { path: toPosix(path.relative(root, metadataFile)), sha256Before: metadataBefore, sha256After: evidenceOf(metadataFile).sha256 }, files,
170
+ ...(refreshOps.length ? { refresh: refreshOps } : {}),
171
+ };
172
+ const absoluteEvidence = path.resolve(evidencePath);
173
+ mkdirSync(path.dirname(absoluteEvidence), { recursive: true });
174
+ writeFileSync(absoluteEvidence, `${JSON.stringify(result, null, 2)}\n`);
175
+ return result;
176
+ }
177
+
178
+ export function main(args = process.argv.slice(2), options = {}) {
179
+ const take = (flag) => {
180
+ const index = args.indexOf(flag);
181
+ if (index < 0) return undefined;
182
+ const value = args[index + 1];
183
+ if (!value || value.startsWith("--")) fail("SIGN_DEV_ARTIFACT_USAGE", `${flag} requires a value`);
184
+ return value;
185
+ };
186
+ try {
187
+ const result = signDevArtifact({
188
+ artifact: take("--artifact"), manifestPath: take("--manifest"), metadataPath: take("--metadata"), evidencePath: take("--evidence"),
189
+ dryRun: args.includes("--dry-run"), ...options,
190
+ });
191
+ console.log(result.dryRun ? `dry-run: recorded hashes verified; would sign ${result.files.map((file) => file.path).join(", ")}` : `signed ${result.files.length} file(s) as ${result.teamIdentifier}: ${result.files.map((file) => `${file.path} (${file.identifier})`).join(", ")}`);
192
+ } catch (error) {
193
+ console.error(`right-release sign-dev-artifact: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
194
+ process.exitCode = 1;
195
+ }
196
+ }
197
+
198
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();
@@ -0,0 +1,60 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { replaceFileInPlace } from "./in-place-install.mjs";
4
+ import { resolveTargetRoot } from "./cargo-target.mjs";
5
+
6
+ /**
7
+ * A stable staged path for a development or QA binary.
8
+ *
9
+ * The build broker writes Cargo output to per-generation folders (`<managed>/rightkit-build-control/ws/<hash>/g/<gen>/...`)
10
+ * and `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. Those are build output only: their path is not
11
+ * stable, so NOTHING may run from them. macOS ties privacy grants to the path an app runs from, so a dev or QA binary is
12
+ * copied in place to `<root>/.rightkit-dev-bin/<triple-or-host>/<name>` (keeping `<name>.previous`) and run from there.
13
+ */
14
+ export const DEV_STAGE_DIRECTORY = ".rightkit-dev-bin";
15
+
16
+ export function stagedBinaryPath({ root, name, triple = null }) {
17
+ if (typeof name !== "string" || !name || name !== path.basename(name) || name === "." || name === "..") throw new Error("staged binary name must be a bare file name");
18
+ if (triple !== null && !/^[A-Za-z0-9_.-]+$/.test(triple)) throw new Error("staged binary triple is not a safe path segment");
19
+ return path.join(path.resolve(root), DEV_STAGE_DIRECTORY, triple ?? "host", name);
20
+ }
21
+
22
+ /** Copy `source` to its stable staged path in place and return that path. Never launches or keeps anything per build. */
23
+ export function stageDevelopmentBinary({ root, source, name = path.basename(source), triple = null }) {
24
+ const from = path.resolve(root, source);
25
+ if (!statSync(from).isFile()) throw new Error(`staged binary source is not a file: ${from}`);
26
+ const destination = stagedBinaryPath({ root, name, triple });
27
+ replaceFileInPlace(from, destination);
28
+ return destination;
29
+ }
30
+
31
+ // The dev and test profiles are written to debug and bench to release (same mapping as development.mjs).
32
+ const profileDirectory = (profile) => (profile === "dev" || profile === "test" ? "debug" : profile === "bench" ? "release" : profile);
33
+
34
+ /** `{ source }` or `{ manifestPath, bin, targetTriple?, profile? }` (resolved through Cargo's own target directory, broker or not). */
35
+ export function resolveStageSource(entry, { root, resolveTarget = resolveTargetRoot, platform = process.platform } = {}) {
36
+ if (entry.source) return path.resolve(root, entry.source);
37
+ if (!entry.manifestPath || !entry.bin) throw new Error("development stage entry needs source, or manifestPath and bin");
38
+ const target = resolveTarget(path.resolve(root, entry.manifestPath));
39
+ const file = platform === "win32" ? `${entry.bin}.exe` : entry.bin;
40
+ return path.join(target, ...(entry.targetTriple ? [entry.targetTriple] : []), profileDirectory(entry.profile ?? "dev"), file);
41
+ }
42
+
43
+ export function validateStageEntries(entries, label) {
44
+ if (entries === undefined) return [];
45
+ if (!Array.isArray(entries)) throw new Error(`${label} must be an array`);
46
+ for (const [index, entry] of entries.entries()) {
47
+ if (!entry || typeof entry !== "object" || (!entry.source && !(entry.manifestPath && entry.bin))) throw new Error(`${label}[${index}] needs source, or manifestPath and bin`);
48
+ if (entry.name !== undefined) stagedBinaryPath({ root: ".", name: entry.name });
49
+ if (entry.targetTriple !== undefined) stagedBinaryPath({ root: ".", name: "x", triple: entry.targetTriple });
50
+ }
51
+ return entries;
52
+ }
53
+
54
+ export function stageDevelopmentBinaries(entries, { root, platform = process.platform, resolveTarget } = {}) {
55
+ return entries.map((entry) => {
56
+ const source = resolveStageSource(entry, { root, platform, resolveTarget });
57
+ const name = entry.name ?? path.basename(source);
58
+ return stageDevelopmentBinary({ root, source, name, triple: entry.targetTriple ?? null });
59
+ });
60
+ }
@@ -0,0 +1,105 @@
1
+ // The Developer-ID-signed `bin/darwin/rightkit-swap` that @rightkit/release ships (Orthic-Labs/rightkit#258).
2
+ // rightkit-swap is the bin target of crates/rightkit-fs (renamex_np RENAME_SWAP). CI has no Apple signing secrets, so
3
+ // @rightkit/release is published through `right-release publish npm --local` on the Mac, which calls
4
+ // `prepareSwapHelper` before packing. install-app calls `assertShippedSwapHelperSigned` before it trusts the helper.
5
+ import crypto from "node:crypto";
6
+ import nodeFs from "node:fs";
7
+ import path from "node:path";
8
+ import { execFileSync as nodeExecFileSync, spawnSync as nodeSpawnSync } from "./background-process.mjs";
9
+ import { signMacosEntries } from "./mac-sign-core.mjs";
10
+ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
11
+ import { runMacCodesignCommands } from "./macos-signing-session.mjs";
12
+
13
+ export const SWAP_HELPER_PACKAGE = "@rightkit/release";
14
+ export const SWAP_HELPER_BIN = ["bin", "darwin", "rightkit-swap"];
15
+ export const SWAP_HELPER_RECEIPT = ["bin", "darwin", "rightkit-swap.sign.json"];
16
+ // rightkit-swap is gated on the crate feature "fs" (crates/rightkit-fs/Cargo.toml [[bin]] required-features).
17
+ export const SWAP_HELPER_CARGO_ARGS = ["build", "--release", "-p", "rightkit-fs", "--features", "fs", "--bin", "rightkit-swap"];
18
+
19
+ /** Typed error. Codes: SWAP_HELPER_BUILD_FAILED, SWAP_HELPER_SIGN_FAILED, SWAP_HELPER_MISSING, SWAP_HELPER_UNSIGNED. */
20
+ export class SwapHelperError extends Error {
21
+ constructor(code, message) { super(message); this.name = "SwapHelperError"; this.code = code; }
22
+ }
23
+
24
+ const helperPath = (packageDir) => path.join(packageDir, ...SWAP_HELPER_BIN);
25
+ const receiptPath = (packageDir) => path.join(packageDir, ...SWAP_HELPER_RECEIPT);
26
+ const sha256 = (fs, file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
27
+ const tail = (text, lines = 30) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
28
+
29
+ /** True when `codesign --verify --strict` accepts the file. Only meaningful on macOS. */
30
+ export function isSwapHelperSigned(file, { execFileSync = nodeExecFileSync } = {}) {
31
+ try { execFileSync("codesign", ["--verify", "--strict", file], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); return true; }
32
+ catch { return false; }
33
+ }
34
+
35
+ /**
36
+ * Local-pack step. darwin: build through the managed cargo wrapper (`run("cargo", ...)` is `rightkit cargo` in
37
+ * local-publish), copy to <packageDir>/bin/darwin/rightkit-swap, Developer ID sign with the shared identity
38
+ * resolver and `macCodesignArgs` (--options runtime --timestamp), verify strictly, write a sha256 receipt.
39
+ * Anywhere else: refuse unless the helper is present and its receipt matches its bytes. Fails closed with SwapHelperError.
40
+ */
41
+ export function prepareSwapHelper({
42
+ workspaceRoot, packageDir = path.join(workspaceRoot, "packages", "release"), platform = process.platform, run, env = process.env, fs = nodeFs,
43
+ resolveIdentity = resolveMacosDeveloperIdIdentity,
44
+ signOne = (codesignArgs) => runMacCodesignCommands([codesignArgs])[0],
45
+ // codesign --verify / -dvv write to stderr: return both streams (as sign-dev-artifact defaultInspect does).
46
+ inspect = (cmd, args) => {
47
+ const result = nodeSpawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
48
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
49
+ if (result.error || result.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed: ${output.trim() || result.error?.message}`);
50
+ return output;
51
+ },
52
+ log = () => {},
53
+ }) {
54
+ const dest = helperPath(packageDir);
55
+ if (platform !== "darwin") {
56
+ let receipt = null;
57
+ try { receipt = JSON.parse(fs.readFileSync(receiptPath(packageDir), "utf8")); } catch { /* refused below */ }
58
+ if (!fs.existsSync(dest) || receipt?.schema !== 1 || receipt.sha256 !== sha256(fs, dest)) {
59
+ throw new SwapHelperError("SWAP_HELPER_MISSING", `${SWAP_HELPER_PACKAGE} must ship a signed ${SWAP_HELPER_BIN.join("/")}; it is absent or unsigned here (${platform}). Run \`right-release publish npm --package ${SWAP_HELPER_PACKAGE} --local\` on the Mac, where the Developer ID identity lives.`);
60
+ }
61
+ return { file: dest, signed: true, built: false, identity: receipt.identity ?? null };
62
+ }
63
+ const fail = (code, message) => { throw new SwapHelperError(code, message); };
64
+ const built = run("cargo", SWAP_HELPER_CARGO_ARGS, { cwd: workspaceRoot, env });
65
+ if (built.error || built.status !== 0) fail("SWAP_HELPER_BUILD_FAILED", `rightkit cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} failed (${built.error?.message ?? `exit ${built.status}`})\n${tail(`${built.stdout ?? ""}\n${built.stderr ?? ""}`)}`);
66
+ const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
67
+ let targetDir;
68
+ try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
69
+ if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) fail("SWAP_HELPER_BUILD_FAILED", "cannot read the Cargo target directory to find rightkit-swap");
70
+ // The RustRight broker builds with an explicit --target triple (target/<triple>/release); plain cargo uses target/release.
71
+ const triple = `${process.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
72
+ const candidates = [path.join(targetDir, triple, "release", "rightkit-swap"), path.join(targetDir, "release", "rightkit-swap")];
73
+ const built_ = candidates.find((file) => fs.existsSync(file));
74
+ if (!built_) fail("SWAP_HELPER_BUILD_FAILED", `the build did not produce rightkit-swap (looked in ${candidates.join(", ")})`);
75
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
76
+ fs.rmSync(dest, { force: true });
77
+ fs.copyFileSync(built_, dest);
78
+ fs.chmodSync(dest, 0o755);
79
+ let identity; let records;
80
+ try {
81
+ identity = resolveIdentity({ env });
82
+ // Same Developer ID Application identity, same argv (--force --options runtime --timestamp --sign) and the same strict verify as sign-macos.
83
+ records = signMacosEntries([{ file: dest, options: [], hardenedRuntime: true }], { identity, signOne, inspect });
84
+ } catch (error) {
85
+ fs.rmSync(dest, { force: true });
86
+ fail("SWAP_HELPER_SIGN_FAILED", `cannot Developer-ID sign ${SWAP_HELPER_BIN.join("/")}: ${error.message}`);
87
+ }
88
+ fs.writeFileSync(receiptPath(packageDir), `${JSON.stringify({ schema: 1, sha256: sha256(fs, dest), identity, codesign: records[0]?.codesign ?? "strict-valid" }, null, 2)}\n`);
89
+ log(`[right-release] signed ${SWAP_HELPER_BIN.join("/")} with ${identity}`);
90
+ return { file: dest, signed: true, built: true, identity };
91
+ }
92
+
93
+ /**
94
+ * Runtime guard (install-app, macOS). A shipped helper that exists must be signed; an unsigned one is a typed error.
95
+ * A helper that is absent is NOT an error here: install-app keeps its documented journaled two-rename fallback and
96
+ * emits `swap-fallback` / `replaced {swap}` so the mode that ran is always logged.
97
+ * @returns {{ present: boolean, file: string|null }}
98
+ */
99
+ export function assertShippedSwapHelperSigned({ file, fs = nodeFs, execFileSync = nodeExecFileSync } = {}) {
100
+ if (!file || !fs.existsSync(file)) return { present: false, file: file ?? null };
101
+ if (!isSwapHelperSigned(file, { execFileSync })) {
102
+ throw new SwapHelperError("SWAP_HELPER_UNSIGNED", `${file} is not validly code-signed, so the atomic swap helper cannot be trusted. Reinstall @rightkit/release from a build published with \`right-release publish npm --package ${SWAP_HELPER_PACKAGE} --local\` on the Mac (Developer ID signed), or set RIGHTKIT_SWAP to a signed helper.`);
103
+ }
104
+ return { present: true, file };
105
+ }
@@ -0,0 +1,28 @@
1
+ # RightKit standard Cargo profiles for every app workspace (Adrian, 2026-10-16; Astra-reviewed option C).
2
+ # One development profile: Cargo's built-in `dev`, used by build, test, check, clippy and `right-release dev`.
3
+ # `release-iterate` is retired. Panic behaviour is the same in every app: unwind.
4
+ # Copy these tables verbatim into the app's workspace-root Cargo.toml; the RightKit ownership scanner
5
+ # flags any deviation. Keep the app's existing [profile.release] optimization keys, but set panic = "unwind".
6
+
7
+ [profile.dev]
8
+ opt-level = 1
9
+ debug = "line-tables-only"
10
+ strip = "none"
11
+ debug-assertions = true
12
+ overflow-checks = true
13
+ lto = "off"
14
+ panic = "unwind"
15
+ incremental = true
16
+ codegen-units = 256
17
+ rpath = false
18
+
19
+ # Dependencies: non-incremental so sccache can cache them across apps.
20
+ [profile.dev.package."*"]
21
+ incremental = false
22
+ codegen-units = 16
23
+
24
+ # No test-profile overrides: test inherits dev.
25
+ # The retired release-iterate profile must not appear: delete it and its package overrides.
26
+
27
+ [profile.release]
28
+ panic = "unwind"
@@ -14,6 +14,7 @@ let tier = "";
14
14
  let repo = "";
15
15
  let configName = "right-release.config.mjs";
16
16
  let dryRun = false;
17
+ let apply = false;
17
18
 
18
19
  for (let index = 0; index < args.length; index += 1) {
19
20
  const arg = args[index];
@@ -24,10 +25,19 @@ for (let index = 0; index < args.length; index += 1) {
24
25
  else if (arg === "--repo") repo = args[++index];
25
26
  else if (arg === "--config") configName = args[++index];
26
27
  else if (arg === "--dry-run") dryRun = true;
28
+ else if (arg === "--apply") apply = true;
27
29
  else if (arg === "-h" || arg === "--help") usage(0);
28
30
  else fail(`unknown argument: ${arg}`);
29
31
  }
30
32
 
33
+ // Android: the Google Play internal track, with its own gate and a dry run by default (android-play-upload.mjs). No tier: the
34
+ // internal track has no patch/update feed.
35
+ if (platform === "android") {
36
+ const { main } = await import("./android-play-upload.mjs");
37
+ process.exit(await main({ releaseId, configName, apply, dryRun }));
38
+ }
39
+ if (apply) fail("--apply is Android only; win and mac preview with --dry-run");
40
+
31
41
  // Tier, primary checkout, clean tree, sealed commit, ownership evidence, provider and repository are checked by
32
42
  // the gate that `right-release github --release` also uses; nothing below runs until it passes.
33
43
  let authority;
@@ -72,7 +82,7 @@ function writeJson(file, value) {
72
82
  }
73
83
 
74
84
  function usage(code) {
75
- console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]");
85
+ console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.mjs] [--apply] (Play internal track; dry run unless --apply)");
76
86
  process.exit(code);
77
87
  }
78
88