@rightkit/release 0.6.5 → 0.6.7
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 +196 -11
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +702 -26
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +671 -133
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +20 -12
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +37 -7
- package/command-spec.mjs +4 -5
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +513 -54
- package/development.mjs +5 -5
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-release.mjs +5 -3
- package/github-updater-release.mjs +48 -8
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +175 -8
- package/ios-upload.mjs +4 -4
- package/legion-semantic-contracts.mjs +428 -0
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +46 -3
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +219 -4
- package/publish-update.mjs +19 -14
- package/release-config.mjs +87 -11
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +4 -4
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +178 -170
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +7 -6
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/release-qa-command.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// right-release qa: run RightKit's own QA runner (rightkit-qa) and write the sealed, MAC'd QA receipt.
|
|
3
3
|
//
|
|
4
|
-
// right-release qa --stage source --platform mac|win [--config right-release.config.
|
|
5
|
-
// right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.
|
|
4
|
+
// right-release qa --stage source --platform mac|win [--config right-release.config.json]
|
|
5
|
+
// right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.json]
|
|
6
6
|
// right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] (release-qa-audit.mjs; exit 1 when an uploaded build lacks passing QA)
|
|
7
|
+
// right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]
|
|
8
|
+
// | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>] (release-qa-policy.mjs)
|
|
7
9
|
//
|
|
8
10
|
// The release config declares the app's QA plan:
|
|
9
11
|
// qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
|
|
@@ -20,14 +22,16 @@ import { createHash } from "node:crypto";
|
|
|
20
22
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
21
23
|
import os from "node:os";
|
|
22
24
|
import path from "node:path";
|
|
23
|
-
import {
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
24
26
|
import { spawnSync } from "./background-process.mjs";
|
|
27
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
25
28
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
26
29
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
27
30
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
28
31
|
import { auditMain } from "./release-qa-audit.mjs";
|
|
29
32
|
import { journeyRequiredItem, QA_RUN_ENV, relativePosix, validateJourneysReceipt } from "./release-qa-hardening.mjs";
|
|
30
33
|
import { prepareInstalledArtifacts } from "./release-qa-installed.mjs";
|
|
34
|
+
import { main as policyMain } from "./release-qa-policy.mjs";
|
|
31
35
|
import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
|
|
32
36
|
import {
|
|
33
37
|
buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
|
|
@@ -55,6 +59,32 @@ function resolveRunner({ qa, appRoot, platform = process.platform, runner = spaw
|
|
|
55
59
|
return realpathSync(file);
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
// Windows installs land in `%LOCALAPPDATA%\<Product>`; the runner learns the product folder names from
|
|
63
|
+
// RIGHTKIT_QA_INSTALLED_DIRS. Compared as strings with both separators so the answer never depends on the host OS.
|
|
64
|
+
const WIN_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|\\\\)/;
|
|
65
|
+
const pathParts = (value) => String(value).split(/[\\/]+/).filter(Boolean);
|
|
66
|
+
const productDirOk = (name) => name.length <= 255 && ![".", ".."].includes(name) && !/[\u0000-\u001f\u007f;:]/.test(name);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Product folder names under %LOCALAPPDATA% that hold the app's installed copy, from the release config's
|
|
70
|
+
* `development.targets.win.install.expectedInstalledPath` (the one place a config names the installed path; the
|
|
71
|
+
* desktop-app-template sets it to `<LOCALAPPDATA>\<Product>\<exe>`). Only an absolute path inside LOCALAPPDATA
|
|
72
|
+
* counts (case-insensitive, either separator); the name is the first component below it and a file must sit under it.
|
|
73
|
+
* `Programs` is already a runner root, so it is dropped. Returns an array of distinct names (today at most one: the
|
|
74
|
+
* config declares one installed path).
|
|
75
|
+
*/
|
|
76
|
+
export function installedDirNames(config, env = process.env) {
|
|
77
|
+
const localAppData = Object.entries(env ?? {}).find(([key]) => key.toLowerCase() === "localappdata")?.[1];
|
|
78
|
+
const installed = config?.development?.targets?.win?.install?.expectedInstalledPath;
|
|
79
|
+
if (typeof localAppData !== "string" || !WIN_ABSOLUTE.test(localAppData) || typeof installed !== "string" || !WIN_ABSOLUTE.test(installed)) return [];
|
|
80
|
+
const root = pathParts(localAppData).map((part) => part.toLowerCase());
|
|
81
|
+
const parts = pathParts(installed);
|
|
82
|
+
if (parts.some((part) => part === "." || part === "..")) return [];
|
|
83
|
+
if (parts.length < root.length + 2 || !root.every((part, index) => parts[index].toLowerCase() === part)) return [];
|
|
84
|
+
const name = parts[root.length];
|
|
85
|
+
return name.toLowerCase() === "programs" || !productDirOk(name) ? [] : [name];
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
/** `rightkit-qa journeys run` prints `run <id> (<run dir>)` first; the receipt is `<run dir>/receipt.json`. */
|
|
59
89
|
export function journeysReceiptFromOutput(stdout) {
|
|
60
90
|
const dir = String(stdout ?? "").match(/^run \S+ \((.+)\)\s*$/m)?.[1];
|
|
@@ -77,7 +107,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
77
107
|
if (!deps.skipPrimaryCheckout) assertPrimaryReleaseCheckout(repoRoot);
|
|
78
108
|
assertCleanSource({ status: String(gitRunner("git", ["-C", repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { encoding: "utf8", windowsHide: true }).stdout ?? ""), commandId: "right-release qa" });
|
|
79
109
|
const source = sourceIdentity(repoRoot, gitRunner);
|
|
80
|
-
const config =
|
|
110
|
+
const config = await importReleaseConfig(configPath, { query: `qa=${source.commit}` });
|
|
81
111
|
if (!config?.app || !config?.version) throw new Error("release config must expose app and version");
|
|
82
112
|
const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
|
|
83
113
|
const problems = validateAppQaConfig(config.qa, floor, stage);
|
|
@@ -117,7 +147,11 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
117
147
|
try {
|
|
118
148
|
const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
|
|
119
149
|
.replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
|
|
120
|
-
const
|
|
150
|
+
const installedDirs = installedDirNames(config, deps.env ?? process.env);
|
|
151
|
+
const runEnv = {
|
|
152
|
+
...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId,
|
|
153
|
+
...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedDirs.length ? { RIGHTKIT_QA_INSTALLED_DIRS: installedDirs.join(";") } : {}), ...(installedQa?.env ?? {}),
|
|
154
|
+
};
|
|
121
155
|
const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
|
|
122
156
|
run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
|
|
123
157
|
cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
|
|
@@ -181,13 +215,14 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
181
215
|
}
|
|
182
216
|
|
|
183
217
|
function usage(code) {
|
|
184
|
-
console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.
|
|
218
|
+
console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.json] [--carry-to <dir>]\n (seals the QA receipt; admission is warn or refuse per app in qa-admission-policy.json; refused inside a QA run)\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]\n right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]\n | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]");
|
|
185
219
|
process.exit(code);
|
|
186
220
|
}
|
|
187
221
|
|
|
188
222
|
export async function main(args) {
|
|
189
223
|
if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
|
|
190
|
-
|
|
224
|
+
if (args[0] === "policy") process.exit(policyMain(args.slice(1), { program: "right-release qa policy" }));
|
|
225
|
+
let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = defaultReleaseConfigFile(), carryTo = null;
|
|
191
226
|
for (let index = 0; index < args.length; index += 1) {
|
|
192
227
|
const arg = args[index];
|
|
193
228
|
if (arg === "--stage") stage = args[++index];
|
package/release-qa-hardening.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// passed", not "one matching check passed".
|
|
8
8
|
// admit side `hardenQaReceipt` re-checks the sealed receipt at admission, independently of what the run side
|
|
9
9
|
// claimed: platform and release id, the approved runner digest, the critical journey set recomputed
|
|
10
|
-
// from the manifest committed at the receipt's commit, and the signing/test-execution separation.
|
|
10
|
+
// from the manifest committed at the receipt's commit, and the signing/test-execution separation. The
|
|
11
|
+
// runner digest must be approved for the platform being sealed or published (qa-admission-policy.json).
|
|
11
12
|
//
|
|
12
13
|
// Signing outside test execution, what is checked here: the receipt records the pids of the test processes it
|
|
13
14
|
// spawned and the window in which they ran; admission refuses a receipt whose window does not end before the
|
|
@@ -119,10 +120,16 @@ export function hardenQaReceipt({
|
|
|
119
120
|
if (receipt.platform !== platform) refuse(`receipt is for platform ${receipt.platform}, this is ${platform}`);
|
|
120
121
|
if (receipt.releaseId !== releaseId) refuse(`receipt is for release ${receipt.releaseId}, this is ${releaseId}`);
|
|
121
122
|
|
|
122
|
-
// 2. The qualifying runner is pinned by an approved digest
|
|
123
|
+
// 2. The qualifying runner is pinned by an approved digest, approved for this platform (an entry with no platform
|
|
124
|
+
// approves every platform).
|
|
123
125
|
const approved = approvedRunnerDigests(policy);
|
|
124
126
|
if (!approved.length) refuse("no rightkit-qa runner digest is approved in qa-admission-policy.json");
|
|
125
|
-
|
|
127
|
+
const digest = receipt.runner?.sha256;
|
|
128
|
+
if (!approved.includes(digest)) refuse(`rightkit-qa runner sha256 ${String(digest).slice(0, 16)} is not an approved runner`);
|
|
129
|
+
const entries = policy.approvedRunners.filter((runner) => runner.sha256 === digest);
|
|
130
|
+
if (!entries.some((runner) => !runner.platform || runner.platform === platform)) {
|
|
131
|
+
refuse(`rightkit-qa runner sha256 ${digest.slice(0, 16)} is approved only for ${[...new Set(entries.map((runner) => runner.platform))].sort().join(", ")}, this is ${platform}`);
|
|
132
|
+
}
|
|
126
133
|
|
|
127
134
|
// 3. The journey floor id means every release-critical journey required on this platform passed.
|
|
128
135
|
const mapping = floor?.journeys;
|
package/release-qa-policy.mjs
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
|
-
// QA admission policy (ADR-0001 item 10): the per-app warn -> refuse switch and the approved rightkit-qa
|
|
2
|
-
//
|
|
1
|
+
// QA admission policy (ADR-0001 item 10): the per-app, per-stage warn -> refuse switch and the approved rightkit-qa
|
|
2
|
+
// runners, recorded in `qa-admission-policy.json` next to this module.
|
|
3
3
|
//
|
|
4
4
|
// { "schema": 1,
|
|
5
|
-
// "apps": { "<app>": { "mode": "warn" | "refuse", "since": "YYYY-MM-DD", "set_by": "<name>"
|
|
6
|
-
//
|
|
5
|
+
// "apps": { "<app>": { "mode": "warn" | "refuse", "since": "YYYY-MM-DD", "set_by": "<name>",
|
|
6
|
+
// "stages": { "source": { "mode": .., "since": .., "set_by": .. },
|
|
7
|
+
// "installed": { "mode": .., "since": .., "set_by": .. } } } },
|
|
8
|
+
// "approvedRunners": [ { "sha256": "<64 hex>", "version": "<rightkit-qa version>", "platform": "mac" | "win",
|
|
9
|
+
// "set_by": "<name>", "since": "YYYY-MM-DD", "note": "..." } ] }
|
|
7
10
|
//
|
|
8
11
|
// An app with no entry is in `warn` mode (default). In `refuse` mode `admit()` throws instead of warning.
|
|
9
12
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// Stages: the build (`source`) and publication (`installed`) admissions each have their own mode. An app entry is the
|
|
14
|
+
// app-level { mode, since, set_by } (governs both stages), a `stages` object, or both; a stage entry overrides the
|
|
15
|
+
// app-level mode for that stage. An app entry needs a mode or a non-empty `stages`. Only `source` and `installed` are
|
|
16
|
+
// stage keys.
|
|
17
|
+
//
|
|
18
|
+
// Approved runners: `sha256` is required (lowercase hex). `platform` ("mac" | "win") binds the approval to the
|
|
19
|
+
// platform the receipt is sealed or published for; an entry without it approves the digest on every platform (legacy
|
|
20
|
+
// entries). `version`, `set_by`, `since` and `note` record who approved which rightkit-qa build and when. Add an entry
|
|
21
|
+
// with `approve-runner`, which checks the file's `--version` output against the published rightkit-qa version.
|
|
22
|
+
//
|
|
23
|
+
// Only the owner (`adrian`) may loosen admission. What is checkable here, and enforced:
|
|
24
|
+
// - every write goes through `checkPolicyTransition`, which refuses (a) a downgrade of any app and stage from refuse
|
|
25
|
+
// to anything else (a warn entry, a removed stage entry or a removed app) whose deciding entry is not
|
|
26
|
+
// `set_by: "adrian"`, and (b) a new approved runner (a sha256 + platform pair not in the previous policy) that is
|
|
27
|
+
// not `set_by: "adrian"`; removing a runner tightens admission and is always allowed;
|
|
14
28
|
// - every load compares the working file with the copy committed at `baselineRef` (default HEAD) and refuses a
|
|
15
|
-
// hand-edited
|
|
29
|
+
// hand-edited loosening the same way, so an uncommitted edit cannot quietly switch an app off refuse or approve a
|
|
30
|
+
// runner. A baseline ref that was asked for explicitly (the argument or RIGHT_RELEASE_QA_POLICY_BASELINE_REF) and
|
|
31
|
+
// cannot be read is a hard error; only the default (HEAD, nothing requested) falls back to no baseline, for a
|
|
32
|
+
// tree that is not a git checkout (an app's node_modules);
|
|
16
33
|
// - an unreadable or malformed policy file is a hard error, never a silent fall back to warn.
|
|
17
34
|
// What is NOT checkable from this file: `set_by` is a recorded claim, not an authenticated identity, and a
|
|
18
|
-
//
|
|
35
|
+
// loosening that was already committed is the new baseline. The file must therefore be owner-protected in the
|
|
19
36
|
// repository (CODEOWNERS on this path plus branch protection, and the file belongs in the protected policy bundle,
|
|
20
37
|
// see ownership-policy-bundle.mjs). CI should set RIGHT_RELEASE_QA_POLICY_BASELINE_REF=origin/main so the baseline
|
|
21
38
|
// is the protected branch rather than the local HEAD.
|
|
@@ -33,7 +50,11 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
33
50
|
export const QA_ADMISSION_POLICY_FILE = "qa-admission-policy.json";
|
|
34
51
|
export const QA_POLICY_BASELINE_REF_ENV = "RIGHT_RELEASE_QA_POLICY_BASELINE_REF";
|
|
35
52
|
export const ADMISSION_MODES = Object.freeze(["warn", "refuse"]);
|
|
36
|
-
/** The
|
|
53
|
+
/** The admissions an app's mode can be set for: the build (source) and the publication (installed). */
|
|
54
|
+
export const ADMISSION_STAGES = Object.freeze(["source", "installed"]);
|
|
55
|
+
/** The platforms an approved runner can be bound to. */
|
|
56
|
+
export const RUNNER_PLATFORMS = Object.freeze(["mac", "win"]);
|
|
57
|
+
/** The only `set_by` that may move an app back from refuse, or approve a runner. */
|
|
37
58
|
export const POLICY_OWNER = "adrian";
|
|
38
59
|
|
|
39
60
|
export class QaPolicyError extends Error {
|
|
@@ -44,42 +65,98 @@ export class QaPolicyError extends Error {
|
|
|
44
65
|
}
|
|
45
66
|
}
|
|
46
67
|
|
|
68
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
69
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
70
|
+
|
|
71
|
+
function validateModeEntry(entry, label, problems) {
|
|
72
|
+
if (!ADMISSION_MODES.includes(entry.mode)) problems.push(`${label} mode must be warn or refuse`);
|
|
73
|
+
if (typeof entry.since !== "string" || !DATE.test(entry.since)) problems.push(`${label} since must be YYYY-MM-DD`);
|
|
74
|
+
if (!nonEmpty(entry.set_by)) problems.push(`${label} set_by is required`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateRunnerEntry(runner, problems) {
|
|
78
|
+
const label = `approvedRunners entry ${runner.sha256.slice(0, 16)}`;
|
|
79
|
+
if (runner.version !== undefined && !nonEmpty(runner.version)) problems.push(`${label} version must be a non-empty string`);
|
|
80
|
+
if (runner.platform !== undefined && !RUNNER_PLATFORMS.includes(runner.platform)) problems.push(`${label} platform must be ${RUNNER_PLATFORMS.join(" or ")}`);
|
|
81
|
+
if (runner.set_by !== undefined && !nonEmpty(runner.set_by)) problems.push(`${label} set_by must be a non-empty string`);
|
|
82
|
+
if (runner.since !== undefined && (typeof runner.since !== "string" || !DATE.test(runner.since))) problems.push(`${label} since must be YYYY-MM-DD`);
|
|
83
|
+
if (runner.note !== undefined && typeof runner.note !== "string") problems.push(`${label} note must be a string`);
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
export function validateAdmissionPolicy(policy) {
|
|
48
87
|
const problems = [];
|
|
49
|
-
if (!
|
|
88
|
+
if (!isObject(policy)) return ["policy must be an object"];
|
|
50
89
|
if (policy.schema !== 1) problems.push("policy schema must be 1");
|
|
51
|
-
if (!
|
|
90
|
+
if (!isObject(policy.apps)) problems.push("policy apps must be an object");
|
|
52
91
|
else {
|
|
53
92
|
for (const [app, entry] of Object.entries(policy.apps)) {
|
|
93
|
+
const label = `policy app ${app}`;
|
|
54
94
|
if (!app.trim()) problems.push("policy app name is empty");
|
|
55
|
-
if (!entry || typeof entry !== "object") { problems.push(
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
if (!entry || typeof entry !== "object") { problems.push(`${label} must be an object`); continue; }
|
|
96
|
+
if (entry.stages !== undefined) {
|
|
97
|
+
if (!isObject(entry.stages)) problems.push(`${label} stages must be an object`);
|
|
98
|
+
else {
|
|
99
|
+
if (!Object.keys(entry.stages).length && entry.mode === undefined) problems.push(`${label} needs a mode or a non-empty stages object`);
|
|
100
|
+
for (const [stage, stageEntry] of Object.entries(entry.stages)) {
|
|
101
|
+
if (!ADMISSION_STAGES.includes(stage)) { problems.push(`${label} has unknown stage ${JSON.stringify(stage)} (expected ${ADMISSION_STAGES.join(" or ")})`); continue; }
|
|
102
|
+
if (!isObject(stageEntry)) { problems.push(`${label} stage ${stage} must be an object`); continue; }
|
|
103
|
+
validateModeEntry(stageEntry, `${label} stage ${stage}`, problems);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// The app-level fields are required unless the app is governed by its stages alone.
|
|
108
|
+
if (entry.mode !== undefined || entry.stages === undefined) validateModeEntry(entry, label, problems);
|
|
59
109
|
}
|
|
60
110
|
}
|
|
61
111
|
if (!Array.isArray(policy.approvedRunners)) problems.push("policy approvedRunners must be an array");
|
|
62
112
|
else {
|
|
63
113
|
for (const runner of policy.approvedRunners) {
|
|
64
114
|
if (!runner || typeof runner.sha256 !== "string" || !HEX64.test(runner.sha256)) problems.push("approvedRunners entries need a lowercase 64-hex sha256");
|
|
115
|
+
else validateRunnerEntry(runner, problems);
|
|
65
116
|
}
|
|
66
117
|
}
|
|
67
118
|
return problems;
|
|
68
119
|
}
|
|
69
120
|
|
|
70
|
-
/**
|
|
121
|
+
/** The entry that decides `stage` for `app`: its stage entry, else its app-level entry when that has a mode, else none. */
|
|
122
|
+
function decidingEntry(policy, app, stage) {
|
|
123
|
+
const entry = policy?.apps?.[app];
|
|
124
|
+
return entry?.stages?.[stage] ?? (entry?.mode ? entry : undefined);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const runnerKey = (runner) => `${runner?.sha256}|${runner?.platform ?? ""}`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Problems when `next` loosens `previous`: for every app and stage, a refuse that is gone or now warn without the
|
|
131
|
+
* deciding entry being `set_by: "adrian"`; and every approved runner that is new without `set_by: "adrian"`.
|
|
132
|
+
*/
|
|
71
133
|
export function checkPolicyTransition(previous, next) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
134
|
+
const grouped = new Map();
|
|
135
|
+
const note = (app, stage, body) => {
|
|
136
|
+
const key = `${app}\u0000${body}`;
|
|
137
|
+
if (!grouped.has(key)) grouped.set(key, { app, body, stages: [] });
|
|
138
|
+
grouped.get(key).stages.push(stage);
|
|
139
|
+
};
|
|
140
|
+
for (const app of Object.keys(previous?.apps ?? {})) {
|
|
141
|
+
for (const stage of ADMISSION_STAGES) {
|
|
142
|
+
if (decidingEntry(previous, app, stage)?.mode !== "refuse") continue;
|
|
143
|
+
const after = decidingEntry(next, app, stage);
|
|
144
|
+
if (after?.mode === "refuse") continue;
|
|
145
|
+
if (!after) note(app, stage, `the refuse entry was removed; only ${POLICY_OWNER} may move an app back to warn (set mode warn with set_by "${POLICY_OWNER}")`);
|
|
146
|
+
else if (after.set_by !== POLICY_OWNER) note(app, stage, `refuse -> warn requires set_by "${POLICY_OWNER}", got ${JSON.stringify(after.set_by)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const problems = [...grouped.values()].map(({ app, body, stages }) => `${app} (${stages.join(", ")}): ${body}`);
|
|
150
|
+
const known = new Set((previous?.approvedRunners ?? []).map(runnerKey));
|
|
151
|
+
for (const runner of next?.approvedRunners ?? []) {
|
|
152
|
+
if (known.has(runnerKey(runner)) || runner?.set_by === POLICY_OWNER) continue;
|
|
153
|
+
problems.push(`approvedRunners: runner ${String(runner?.sha256).slice(0, 16)}${runner?.platform ? ` (${runner.platform})` : ""} is new with set_by ${JSON.stringify(runner?.set_by ?? null)}; only ${POLICY_OWNER} may approve a runner (set_by "${POLICY_OWNER}")`);
|
|
78
154
|
}
|
|
79
155
|
return problems;
|
|
80
156
|
}
|
|
81
157
|
|
|
82
158
|
const digestOf = (policy) => createHash("sha256").update(canonicalJson(policy)).digest("hex");
|
|
159
|
+
const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
83
160
|
|
|
84
161
|
function parsePolicyText(text, label) {
|
|
85
162
|
let policy;
|
|
@@ -89,15 +166,20 @@ function parsePolicyText(text, label) {
|
|
|
89
166
|
return policy;
|
|
90
167
|
}
|
|
91
168
|
|
|
169
|
+
/** { text } when git shows the policy file at `ref`; { text: null, detail } when it cannot. */
|
|
92
170
|
function gitBaselineText({ dir, ref, runner = spawnSync }) {
|
|
93
171
|
try {
|
|
94
172
|
const result = runner("git", ["-C", dir, "show", `${ref}:./${QA_ADMISSION_POLICY_FILE}`], { encoding: "utf8", windowsHide: true });
|
|
95
|
-
|
|
96
|
-
|
|
173
|
+
if (result?.status === 0) return { text: String(result.stdout) };
|
|
174
|
+
return { text: null, detail: String(result?.stderr ?? "").trim().split(/\r?\n/)[0] || `git exited ${result?.status ?? "without a status"}` };
|
|
175
|
+
} catch (error) { return { text: null, detail: error.message }; }
|
|
97
176
|
}
|
|
98
177
|
|
|
99
178
|
/**
|
|
100
179
|
* Load the policy. `baseline` (policy text) overrides the git lookup; `baseline: false` disables the comparison.
|
|
180
|
+
* A baseline ref requested explicitly (`baselineRef` or the env var) that cannot be read is an error: the comparison
|
|
181
|
+
* the caller asked for must not silently become no comparison. With no ref requested the baseline is HEAD, and a tree
|
|
182
|
+
* that is not a git checkout has none.
|
|
101
183
|
* Returns { policy, digest, baseline: "supplied" | "git" | "none" }.
|
|
102
184
|
*/
|
|
103
185
|
export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = process.env, gitRunner } = {}) {
|
|
@@ -108,7 +190,11 @@ export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = p
|
|
|
108
190
|
let baselineText = null;
|
|
109
191
|
if (typeof baseline === "string") { baselineText = baseline; source = "supplied"; }
|
|
110
192
|
else if (baseline !== false) {
|
|
111
|
-
|
|
193
|
+
const requested = [baselineRef, env?.[QA_POLICY_BASELINE_REF_ENV]].find(nonEmpty);
|
|
194
|
+
const ref = requested ?? "HEAD";
|
|
195
|
+
const found = gitBaselineText({ dir, ref, runner: gitRunner });
|
|
196
|
+
if (found.text === null && requested) throw new QaPolicyError(`QA admission policy baseline ${ref} cannot be read (${found.detail}); refusing to continue without the comparison that was requested`);
|
|
197
|
+
baselineText = found.text;
|
|
112
198
|
if (baselineText !== null) source = "git";
|
|
113
199
|
}
|
|
114
200
|
if (baselineText !== null) {
|
|
@@ -118,46 +204,127 @@ export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = p
|
|
|
118
204
|
return { policy, digest: digestOf(policy), baseline: source };
|
|
119
205
|
}
|
|
120
206
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
207
|
+
/**
|
|
208
|
+
* The mode for an app at `stage` ("source" | "installed"): the stage entry, else the app-level entry, else `warn`.
|
|
209
|
+
* Without a stage it is the app-level result.
|
|
210
|
+
*/
|
|
211
|
+
export function admissionModeFor(policy, app, stage) {
|
|
212
|
+
const entry = stage ? decidingEntry(policy, app, stage) : policy?.apps?.[app];
|
|
124
213
|
return entry?.mode === "refuse" ? { mode: "refuse", since: entry.since, set_by: entry.set_by } : { mode: "warn", since: entry?.since ?? null, set_by: entry?.set_by ?? null };
|
|
125
214
|
}
|
|
126
215
|
|
|
127
216
|
export const approvedRunnerDigests = (policy) => (policy?.approvedRunners ?? []).map((runner) => runner.sha256);
|
|
128
217
|
|
|
218
|
+
/** Write the policy atomically, apps and runners in a deterministic order. */
|
|
219
|
+
function writePolicy(file, policy) {
|
|
220
|
+
const sorted = {
|
|
221
|
+
...policy,
|
|
222
|
+
apps: Object.fromEntries(Object.entries(policy.apps).sort(([a], [b]) => a.localeCompare(b))),
|
|
223
|
+
approvedRunners: [...policy.approvedRunners].sort((a, b) => a.sha256.localeCompare(b.sha256) || (a.platform ?? "").localeCompare(b.platform ?? "")),
|
|
224
|
+
};
|
|
225
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
226
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
227
|
+
writeFileSync(temp, `${JSON.stringify(sorted, null, 2)}\n`);
|
|
228
|
+
renameSync(temp, file);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Refuse a result that loosens `current` or is not a valid policy; the file is left untouched. */
|
|
232
|
+
function assertAllowed(current, next) {
|
|
233
|
+
const problems = [...checkPolicyTransition(current, next), ...validateAdmissionPolicy(next)];
|
|
234
|
+
if (problems.length) throw new QaPolicyError(`QA admission policy change refused: ${problems.join("; ")}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
129
237
|
/**
|
|
130
|
-
* Record an app's mode.
|
|
238
|
+
* Record an app's mode. With a `stage` only that stage's entry is written (the app-level mode and the other stage are
|
|
239
|
+
* kept); without one the app-level entry is written and any `stages` are removed, so one mode governs both stages.
|
|
240
|
+
* A refuse -> warn move at any stage is refused unless `setBy` is the owner. Writes atomically.
|
|
131
241
|
* The file is read without the git baseline here (the transition rule is applied directly to what is on disk).
|
|
242
|
+
* Returns the entry written.
|
|
132
243
|
*/
|
|
133
|
-
export function setAdmissionMode({ app, mode, setBy, dir = here, now = () => new Date() }) {
|
|
244
|
+
export function setAdmissionMode({ app, mode, setBy, stage, dir = here, now = () => new Date() }) {
|
|
134
245
|
if (!app || !String(app).trim()) throw new QaPolicyError("app is required");
|
|
135
246
|
if (!ADMISSION_MODES.includes(mode)) throw new QaPolicyError("mode must be warn or refuse");
|
|
136
247
|
if (!setBy || !String(setBy).trim()) throw new QaPolicyError("set_by is required");
|
|
248
|
+
if (stage !== undefined && !ADMISSION_STAGES.includes(stage)) throw new QaPolicyError(`stage must be ${ADMISSION_STAGES.join(" or ")}`);
|
|
137
249
|
const file = path.join(dir, QA_ADMISSION_POLICY_FILE);
|
|
138
250
|
const current = parsePolicyText(readFileSync(file, "utf8"), QA_ADMISSION_POLICY_FILE);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
251
|
+
const entry = { mode, since: now().toISOString().slice(0, 10), set_by: String(setBy) };
|
|
252
|
+
let written;
|
|
253
|
+
if (stage) {
|
|
254
|
+
const stages = { ...current.apps[app]?.stages, [stage]: entry };
|
|
255
|
+
written = { ...current.apps[app], stages: Object.fromEntries(ADMISSION_STAGES.filter((name) => stages[name]).map((name) => [name, stages[name]])) };
|
|
256
|
+
} else written = entry;
|
|
257
|
+
const next = { ...current, apps: { ...current.apps, [app]: written } };
|
|
258
|
+
assertAllowed(current, next);
|
|
259
|
+
writePolicy(file, next);
|
|
260
|
+
return entry;
|
|
148
261
|
}
|
|
149
262
|
|
|
150
|
-
|
|
151
|
-
|
|
263
|
+
function readRunnerBytes(file) {
|
|
264
|
+
try { return readFileSync(file); } catch (error) { throw new QaPolicyError(`runner file is unreadable (${file}): ${error.message}`); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Approve a rightkit-qa runner build for a platform: only the owner may (it loosens admission), the file must report
|
|
269
|
+
* `rightkit-qa <version>` from `--version`, and that version must be the published rightkit-qa version in
|
|
270
|
+
* rightkit-versions.json (`registry.cargo`). Records { sha256, version, platform, set_by, since, note? } and returns it;
|
|
271
|
+
* an entry with the same sha256 and platform that is already present is returned unchanged. Writes atomically.
|
|
272
|
+
*/
|
|
273
|
+
export function approveRunner({
|
|
274
|
+
runner, version, platform, setBy, note, dir = here, now = () => new Date(), spawn = spawnSync, versionsFile = path.join(here, "rightkit-versions.json"),
|
|
275
|
+
}) {
|
|
276
|
+
if (setBy !== POLICY_OWNER) throw new QaPolicyError(`approving a runner loosens QA admission; only ${POLICY_OWNER} may approve one (set_by ${JSON.stringify(setBy ?? null)})`);
|
|
277
|
+
if (!RUNNER_PLATFORMS.includes(platform)) throw new QaPolicyError(`platform must be ${RUNNER_PLATFORMS.join(" or ")}`);
|
|
278
|
+
if (!runner || !String(runner).trim()) throw new QaPolicyError("runner is required");
|
|
279
|
+
if (!nonEmpty(version)) throw new QaPolicyError("version is required");
|
|
280
|
+
if (note !== undefined && typeof note !== "string") throw new QaPolicyError("note must be a string");
|
|
281
|
+
let published;
|
|
282
|
+
try { published = JSON.parse(readFileSync(versionsFile, "utf8")).registry?.cargo?.["rightkit-qa"]; }
|
|
283
|
+
catch (error) { throw new QaPolicyError(`the published rightkit-qa version cannot be read (${versionsFile}): ${error.message}`); }
|
|
284
|
+
if (published !== version) throw new QaPolicyError(`rightkit-qa ${version} is not the published version (rightkit-versions.json registry.cargo has ${published ?? "no rightkit-qa"}); only the published runner can be approved`);
|
|
285
|
+
// The file that is digested is the file that is run: an absolute path, never a PATH lookup, and re-digested after.
|
|
286
|
+
const file = path.resolve(String(runner));
|
|
287
|
+
const sha256 = sha256Of(readRunnerBytes(file));
|
|
288
|
+
const run = spawn(file, ["--version"], { encoding: "utf8", windowsHide: true });
|
|
289
|
+
const output = String(run?.stdout ?? "").trim();
|
|
290
|
+
if (run?.status !== 0 || output !== `rightkit-qa ${version}`) {
|
|
291
|
+
throw new QaPolicyError(`${file} --version reported ${JSON.stringify(output.slice(0, 80))} (exit ${run?.status ?? run?.error?.message ?? "none"}), expected "rightkit-qa ${version}"`);
|
|
292
|
+
}
|
|
293
|
+
if (sha256Of(readRunnerBytes(file)) !== sha256) throw new QaPolicyError(`${file} changed while its version was being checked; nothing was approved`);
|
|
294
|
+
const policyFile = path.join(dir, QA_ADMISSION_POLICY_FILE);
|
|
295
|
+
const current = parsePolicyText(readFileSync(policyFile, "utf8"), QA_ADMISSION_POLICY_FILE);
|
|
296
|
+
const present = current.approvedRunners.find((item) => item.sha256 === sha256 && (item.platform ?? null) === platform);
|
|
297
|
+
if (present) return present;
|
|
298
|
+
const entry = { sha256, version, platform, set_by: setBy, since: now().toISOString().slice(0, 10), ...(note ? { note } : {}) };
|
|
299
|
+
const next = { ...current, approvedRunners: [...current.approvedRunners, entry] };
|
|
300
|
+
assertAllowed(current, next);
|
|
301
|
+
writePolicy(policyFile, next);
|
|
302
|
+
return entry;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `node release-qa-policy.mjs show | set ... | approve-runner ...` (also `right-release qa policy ...`). The options
|
|
307
|
+
* are test seams: `dir` (the policy directory), `env`, `now`, `spawn`, `versionsFile`, and `program` (the usage name).
|
|
308
|
+
*/
|
|
309
|
+
export function main(args, {
|
|
310
|
+
stdout = (text) => console.log(text), stderr = (text) => console.error(text), dir, env, now, spawn, versionsFile, program = "release-qa-policy.mjs",
|
|
311
|
+
} = {}) {
|
|
152
312
|
const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
|
|
153
313
|
try {
|
|
154
|
-
if (args[0] === "show") { stdout(JSON.stringify(loadAdmissionPolicy().policy, null, 2)); return 0; }
|
|
314
|
+
if (args[0] === "show") { stdout(JSON.stringify(loadAdmissionPolicy({ dir, env }).policy, null, 2)); return 0; }
|
|
155
315
|
if (args[0] === "set") {
|
|
156
|
-
|
|
157
|
-
|
|
316
|
+
// A `--stage` with no value must not fall through to the app-level write, which removes the stages.
|
|
317
|
+
const stage = args.includes("--stage") ? value("--stage") ?? "" : undefined;
|
|
318
|
+
const entry = setAdmissionMode({ app: value("--app"), mode: value("--mode"), setBy: value("--set-by"), stage, dir, now });
|
|
319
|
+
stdout(`qa admission: ${value("--app")}${stage ? ` (${stage} stage)` : ""} -> ${entry.mode} (since ${entry.since}, set_by ${entry.set_by})`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
if (args[0] === "approve-runner") {
|
|
323
|
+
const entry = approveRunner({ runner: value("--runner"), version: value("--version"), platform: value("--platform"), setBy: value("--set-by"), note: value("--note"), dir, now, spawn, versionsFile });
|
|
324
|
+
stdout(`qa runner approved: ${entry.sha256} rightkit-qa ${entry.version} (${entry.platform ?? "any platform"}, since ${entry.since}, set_by ${entry.set_by})`);
|
|
158
325
|
return 0;
|
|
159
326
|
}
|
|
160
|
-
stderr(
|
|
327
|
+
stderr(`usage: ${program} show\n ${program} set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]\n ${program} approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]`);
|
|
161
328
|
return 2;
|
|
162
329
|
} catch (error) {
|
|
163
330
|
stderr(`release-qa-policy: ${error.message}`);
|
package/release.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
|
|
|
58
58
|
import { releaseSwitchInputs } from "./ios-build-context.mjs";
|
|
59
59
|
import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
|
|
60
60
|
import { evaluatePrebuildGates } from "./prebuild-gates.mjs";
|
|
61
|
-
import { releaseConfigFieldProblems } from "./release-config.mjs";
|
|
61
|
+
import { defaultReleaseConfigFile, importReleaseConfig, releaseConfigFieldProblems } from "./release-config.mjs";
|
|
62
62
|
import { describeRegistryError, formatNotRun, runAdmission } from "./admission.mjs";
|
|
63
63
|
import { sccacheRefusal } from "./admission-host.mjs";
|
|
64
64
|
|
|
@@ -126,7 +126,7 @@ const SWIFT_PRIVATE_DISPATCH_WORKFLOWS = new Set(["release-candidate.yml", "rege
|
|
|
126
126
|
|
|
127
127
|
const args = process.argv.slice(2);
|
|
128
128
|
const opts = {
|
|
129
|
-
config:
|
|
129
|
+
config: defaultReleaseConfigFile(),
|
|
130
130
|
platform: process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform,
|
|
131
131
|
upload: false,
|
|
132
132
|
install: false,
|
|
@@ -245,7 +245,7 @@ if (opts.doctor) {
|
|
|
245
245
|
doctorInvocation = { repoRoot, commit };
|
|
246
246
|
}
|
|
247
247
|
let config;
|
|
248
|
-
try { config =
|
|
248
|
+
try { config = await importReleaseConfig(configPath); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
|
|
249
249
|
if (!config) usage(2, `config did not export default: ${configPath}`);
|
|
250
250
|
if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
|
|
251
251
|
// Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
|
|
@@ -841,7 +841,7 @@ releaseLock.release();
|
|
|
841
841
|
|
|
842
842
|
function usage(code, message) {
|
|
843
843
|
if (message) console.error(`right-release: ${message}\n`);
|
|
844
|
-
console.error("usage: right-release build --platform mac|win [--config right-release.config.
|
|
844
|
+
console.error("usage: right-release build --platform mac|win [--config right-release.config.json] (build is tier-neutral; tier applies only to: right-release upload --release <id> --platform mac|win --tier patch|update)");
|
|
845
845
|
process.exit(code);
|
|
846
846
|
}
|
|
847
847
|
|
|
@@ -29,6 +29,7 @@ import { parse as parseToml } from "smol-toml";
|
|
|
29
29
|
import { isExcludedScanDirectory } from "./cargo-contract.mjs";
|
|
30
30
|
import { resolveSharedCacheRoot } from "./cache-policy.mjs";
|
|
31
31
|
import { parsePnpmYaml } from "./ownership-lockfiles.mjs";
|
|
32
|
+
import { UPDATE_COMMAND } from "./outdated-command.mjs";
|
|
32
33
|
import { compareVersions } from "./rightkit-sync.mjs";
|
|
33
34
|
|
|
34
35
|
export const SNAPSHOT_SCHEMA = 1;
|
|
@@ -42,7 +43,9 @@ export const REGISTRIES = Object.freeze({
|
|
|
42
43
|
const CRATES_IO_SOURCES = new Set(["registry+https://github.com/rust-lang/crates.io-index", "registry+https://index.crates.io/"]);
|
|
43
44
|
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
44
45
|
const USER_AGENT = "right-release-resolver/1 (https://github.com/bogusyogi/rightkit)";
|
|
45
|
-
|
|
46
|
+
// The updater is @rightkit/outdated, a dependency of this package; `right-release update` runs its bin (pnpm links only an
|
|
47
|
+
// app's direct dependencies' bins, so `pnpm exec right-update` does not exist in an app that has just @rightkit/release).
|
|
48
|
+
export const FIX = `run \`${UPDATE_COMMAND}\` (the pinned @rightkit/outdated updater; it reads .rightkit/update.json) to move the pin`;
|
|
46
49
|
const DEFAULT_ATTEMPTS = 3;
|
|
47
50
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
48
51
|
const CONCURRENCY = 6;
|
|
@@ -589,7 +592,7 @@ function matchesPattern(name, pattern) {
|
|
|
589
592
|
}
|
|
590
593
|
/**
|
|
591
594
|
* Holds named in the nearest .rightkit/update.json (app root up to the repository boundary). A hold is a migration
|
|
592
|
-
* lane task for right-update; it is reported next to a stale pin and never grants permission to seal it.
|
|
595
|
+
* lane task for the updater (right-update); it is reported next to a stale pin and never grants permission to seal it.
|
|
593
596
|
* Only holds the file itself declares are read (policy.held, policy.tier.held); no built-in pattern is applied here.
|
|
594
597
|
*/
|
|
595
598
|
export function readUpdateHolds(root) {
|