@rightkit/release 0.6.5 → 0.6.6
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 +79 -7
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +474 -17
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +431 -44
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +7 -6
- 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 +8 -1
- package/command-spec.mjs +4 -5
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +280 -32
- package/development.mjs +5 -5
- package/github-release.mjs +5 -3
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +134 -8
- package/ios-upload.mjs +4 -4
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/package.json +37 -1
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +215 -3
- package/publish-update.mjs +5 -3
- package/release-config.mjs +36 -5
- 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 +11 -8
- package/stage-sidecar-command.mjs +5 -5
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
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) {
|
package/rightkit-versions.json
CHANGED
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"@rightkit/pdf-ui": "0.2.0",
|
|
31
31
|
"@rightkit/platform-ui": "0.4.0",
|
|
32
32
|
"@rightkit/qa": "0.6.1",
|
|
33
|
-
"@rightkit/qa-chrome": "0.1.
|
|
33
|
+
"@rightkit/qa-chrome": "0.1.12",
|
|
34
34
|
"@rightkit/react-shell": "0.7.2",
|
|
35
|
-
"@rightkit/release": "0.6.
|
|
35
|
+
"@rightkit/release": "0.6.6",
|
|
36
36
|
"@rightkit/settings-ui": "0.7.0",
|
|
37
37
|
"@rightkit/tauri": "0.3.0",
|
|
38
38
|
"@rightkit/tauri-shell": "0.1.3",
|
|
@@ -231,7 +231,8 @@
|
|
|
231
231
|
"0.6.1",
|
|
232
232
|
"0.6.2",
|
|
233
233
|
"0.6.3",
|
|
234
|
-
"0.6.4"
|
|
234
|
+
"0.6.4",
|
|
235
|
+
"0.6.5"
|
|
235
236
|
],
|
|
236
237
|
"@rightkit/qa": [
|
|
237
238
|
"0.1.0",
|
|
@@ -373,7 +374,8 @@
|
|
|
373
374
|
"0.1.5",
|
|
374
375
|
"0.1.6",
|
|
375
376
|
"0.1.8",
|
|
376
|
-
"0.1.10"
|
|
377
|
+
"0.1.10",
|
|
378
|
+
"0.1.11"
|
|
377
379
|
],
|
|
378
380
|
"@rightkit/updates": [
|
|
379
381
|
"0.2.4"
|
|
@@ -486,7 +488,7 @@
|
|
|
486
488
|
"rightkit-pdf": "0.3.3",
|
|
487
489
|
"rightkit-platform-rules": "0.1.0",
|
|
488
490
|
"rightkit-playback": "0.3.0",
|
|
489
|
-
"rightkit-qa": "0.9.
|
|
491
|
+
"rightkit-qa": "0.9.1",
|
|
490
492
|
"rightkit-qa-driver": "0.7.0",
|
|
491
493
|
"rightkit-record": "0.3.3",
|
|
492
494
|
"rightkit-review": "0.4.0",
|
|
@@ -610,7 +612,7 @@
|
|
|
610
612
|
"@rightkit/app-shell": "0.6.2",
|
|
611
613
|
"@rightkit/app-update": "0.4.2",
|
|
612
614
|
"@rightkit/ax": "0.3.2",
|
|
613
|
-
"@rightkit/ci": "0.3.
|
|
615
|
+
"@rightkit/ci": "0.3.1",
|
|
614
616
|
"@rightkit/doc-render": "0.2.1",
|
|
615
617
|
"@rightkit/git": "0.2.42",
|
|
616
618
|
"@rightkit/hooks": "0.1.2",
|
|
@@ -628,7 +630,7 @@
|
|
|
628
630
|
"@rightkit/qa": "0.6.1",
|
|
629
631
|
"@rightkit/qa-chrome": "0.1.11",
|
|
630
632
|
"@rightkit/react-shell": "0.7.2",
|
|
631
|
-
"@rightkit/release": "0.6.
|
|
633
|
+
"@rightkit/release": "0.6.5",
|
|
632
634
|
"@rightkit/settings-ui": "0.7.0",
|
|
633
635
|
"@rightkit/shell": "0.1.1",
|
|
634
636
|
"@rightkit/tauri": "0.3.0",
|
|
@@ -762,7 +764,8 @@
|
|
|
762
764
|
"0.6.4",
|
|
763
765
|
"0.7.0",
|
|
764
766
|
"0.7.1",
|
|
765
|
-
"0.8.0"
|
|
767
|
+
"0.8.0",
|
|
768
|
+
"0.9.0"
|
|
766
769
|
],
|
|
767
770
|
"rightkit-sandbox": [
|
|
768
771
|
"0.1.0",
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `right-release stage-sidecar [--config right-release.config.
|
|
2
|
+
// `right-release stage-sidecar [--config right-release.config.json] [--target <triple>] [--check] [--json]` (bogusyogi/rightkit#345):
|
|
3
3
|
// stages the config's declarative `sidecars` into src-tauri/binaries with Tauri externalBin naming. `--check` validates
|
|
4
4
|
// the manifest and prints the plan without building, downloading, copying or deleting anything (for template gates).
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
7
8
|
import { planSidecarStaging, resolveTargetTriple, stageSidecars } from "./stage-sidecar.mjs";
|
|
8
9
|
|
|
9
|
-
export function parseStageSidecarArgs(argv) {
|
|
10
|
-
const opts = { config:
|
|
10
|
+
export function parseStageSidecarArgs(argv, cwd = process.cwd()) {
|
|
11
|
+
const opts = { config: defaultReleaseConfigFile(cwd), target: undefined, json: false, check: false };
|
|
11
12
|
for (let i = 0; i < argv.length; i += 1) {
|
|
12
13
|
const arg = argv[i];
|
|
13
14
|
if (arg === "--config" || arg === "--target") {
|
|
@@ -49,8 +50,7 @@ async function main() {
|
|
|
49
50
|
const opts = parseStageSidecarArgs(process.argv.slice(2));
|
|
50
51
|
const configPath = path.resolve(opts.config);
|
|
51
52
|
const appRoot = path.dirname(configPath);
|
|
52
|
-
const
|
|
53
|
-
const config = loaded.default ?? loaded.config ?? loaded;
|
|
53
|
+
const config = await importReleaseConfig(configPath, { lenient: true });
|
|
54
54
|
if (config?.sidecars === undefined) throw new Error(`${opts.config} declares no sidecars`);
|
|
55
55
|
if (opts.check) {
|
|
56
56
|
const target = opts.target ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? nodeHostTriple();
|
package/test-command.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `right-release test [--config <file>] [--suite <name>[,<name>]]... [--runner <name>] [--list] [--json] [-- <args>]`
|
|
3
|
-
// runs the app's declared test suites from `right-release.config.
|
|
3
|
+
// runs the app's declared test suites from its release config (`right-release.config.json`), one after another, and stops at the first failure.
|
|
4
4
|
//
|
|
5
5
|
// tests: {
|
|
6
6
|
// eventPrefix: "e2e", // optional; events are `<prefix>_suite_started` / `<prefix>_suite_completed`
|
|
@@ -26,6 +26,7 @@ import path from "node:path";
|
|
|
26
26
|
import { pathToFileURL } from "node:url";
|
|
27
27
|
import { spawn } from "./background-process.mjs";
|
|
28
28
|
import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
|
|
29
|
+
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
29
30
|
|
|
30
31
|
const fail = (message) => { throw new CommandSpecError(message); };
|
|
31
32
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -34,7 +35,7 @@ const PREFIX = /^[a-z][a-z0-9_]*$/;
|
|
|
34
35
|
const uniq = (...lists) => [...new Set(lists.flatMap((list) => list ?? []))];
|
|
35
36
|
|
|
36
37
|
export function parseTestArgs(argv) {
|
|
37
|
-
const opts = { config:
|
|
38
|
+
const opts = { config: defaultReleaseConfigFile(), suites: [], runner: undefined, list: false, json: false, passthrough: [] };
|
|
38
39
|
for (let i = 0; i < argv.length; i += 1) {
|
|
39
40
|
const arg = argv[i];
|
|
40
41
|
if (arg === "--") { opts.passthrough = argv.slice(i + 1); break; }
|
package/upload-release.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { prepareGitHubRelease, publishGitHubRelease } from "./github-release.mjs";
|
|
6
|
+
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
6
7
|
import { authorizeGitHubUpload } from "./github-upload-gate.mjs";
|
|
7
8
|
import { recordPublishedVersion } from "./artifact-storage.mjs";
|
|
8
9
|
import { admitInstalledQa } from "./release-qa-audit.mjs";
|
|
@@ -12,7 +13,7 @@ let platform = process.platform === "win32" ? "win" : process.platform === "darw
|
|
|
12
13
|
let releaseId = "";
|
|
13
14
|
let tier = "";
|
|
14
15
|
let repo = "";
|
|
15
|
-
let configName =
|
|
16
|
+
let configName = defaultReleaseConfigFile();
|
|
16
17
|
let dryRun = false;
|
|
17
18
|
let apply = false;
|
|
18
19
|
|
|
@@ -87,7 +88,7 @@ function writeJson(file, value) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function usage(code) {
|
|
90
|
-
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.
|
|
91
|
+
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.json] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.json] [--apply] (Play internal track; dry run unless --apply)\n right-release upload --platform ios --release <sealed-id> [--config right-release.config.json] [--apply] (TestFlight; dry run unless --apply)");
|
|
91
92
|
process.exit(code);
|
|
92
93
|
}
|
|
93
94
|
|
package/web-build.mjs
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
16
16
|
import { spawnSync } from "./background-process.mjs";
|
|
17
17
|
import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
|
|
18
|
+
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
18
19
|
|
|
19
20
|
export function parseWebBuildArgs(argv) {
|
|
20
|
-
const opts = { config:
|
|
21
|
+
const opts = { config: defaultReleaseConfigFile(), dryRun: false };
|
|
21
22
|
for (let i = 0; i < argv.length; i += 1) {
|
|
22
23
|
const arg = argv[i];
|
|
23
24
|
if (arg === "--config") {
|