@rightkit/git 0.2.24 → 0.2.26
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/cli/right-git.mjs +0 -0
- package/guard-coverage.test.mjs +1 -2
- package/manifest.mjs +61 -6
- package/manifest.schema.json +13 -0
- package/manifest.test.mjs +7 -0
- package/ownership-registration.mjs +33 -0
- package/ownership-registration.test.mjs +20 -0
- package/pack-smoke.test.mjs +8 -8
- package/package.json +5 -6
- package/render.test.mjs +216 -24
- package/sync.mjs +1 -1
- package/templates/ci.yml +1 -1
- package/templates/package-smoke.yml +1 -0
- package/templates/profiles/rust-hybrid/ci.yml +3 -1
- package/templates/profiles/rust-hybrid/release-candidate.yml +16 -2
- package/templates/publish-npm.yml +1 -0
- package/templates/publish-package-managers.yml +1 -0
- package/templates/qualification.yml +1 -0
- package/templates/release-candidate.yml +1 -0
package/cli/right-git.mjs
CHANGED
|
File without changes
|
package/guard-coverage.test.mjs
CHANGED
|
@@ -166,12 +166,11 @@ function isGuarded(jobs, jobName, visiting = new Set()) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function renderAllLanes() {
|
|
169
|
-
const context = buildRenderContext(FULL_MANIFEST, "/repos/widgets");
|
|
170
169
|
const rendered = {};
|
|
171
170
|
for (const lane of FULL_MANIFEST.lanes) {
|
|
172
171
|
const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
|
|
173
172
|
const templateText = fs.readFileSync(templatePath, "utf8");
|
|
174
|
-
rendered[lane] = renderTemplate(templateText,
|
|
173
|
+
rendered[lane] = renderTemplate(templateText, buildRenderContext(FULL_MANIFEST, "/repos/widgets", lane));
|
|
175
174
|
}
|
|
176
175
|
return rendered;
|
|
177
176
|
}
|
package/manifest.mjs
CHANGED
|
@@ -60,6 +60,7 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
60
60
|
"gate",
|
|
61
61
|
"rustCache",
|
|
62
62
|
"candidate",
|
|
63
|
+
"ownership",
|
|
63
64
|
]);
|
|
64
65
|
const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
65
66
|
const RUST_WORKSPACE_PATH_RE = /^(?:\.|(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*)$/;
|
|
@@ -264,7 +265,7 @@ export function validateManifest(manifest) {
|
|
|
264
265
|
// above) — the full signed release chain adds evidenceVerificationScript
|
|
265
266
|
// (runs before publish, verifying aggregated same-run evidence) plus the
|
|
266
267
|
// finalize/qualify/publish scripts.
|
|
267
|
-
const allowedChainKeys = ["evidenceVerificationScript", "windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets"];
|
|
268
|
+
const allowedChainKeys = ["evidenceVerificationScript", "windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets", "environments"];
|
|
268
269
|
if (chain === null || typeof chain !== "object" || Array.isArray(chain)) {
|
|
269
270
|
errors.push("candidate.releaseChain must be an object");
|
|
270
271
|
} else {
|
|
@@ -277,6 +278,19 @@ export function validateManifest(manifest) {
|
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
280
|
if (chain.macosFinalizeScript !== undefined && (typeof chain.macosFinalizeScript !== "string" || !PACKAGE_SCRIPT_RE.test(chain.macosFinalizeScript))) {
|
|
281
|
+
if (chain.environments !== undefined) {
|
|
282
|
+
const environments = chain.environments;
|
|
283
|
+
if (environments === null || typeof environments !== "object" || Array.isArray(environments)) {
|
|
284
|
+
errors.push("candidate.releaseChain.environments must be an object");
|
|
285
|
+
} else {
|
|
286
|
+
for (const key of Object.keys(environments)) {
|
|
287
|
+
if (!["signing", "qualification", "publication"].includes(key)) errors.push(`unknown candidate.releaseChain.environments field: ${key}`);
|
|
288
|
+
else if (typeof environments[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(environments[key])) {
|
|
289
|
+
errors.push(`candidate.releaseChain.environments.${key} must be one safe GitHub environment name`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
280
294
|
errors.push("candidate.releaseChain.macosFinalizeScript must be one safe package script name");
|
|
281
295
|
}
|
|
282
296
|
if (typeof chain.qualificationRunnerVariable !== "string" || !GITHUB_VARIABLE_RE.test(chain.qualificationRunnerVariable)) {
|
|
@@ -298,6 +312,18 @@ export function validateManifest(manifest) {
|
|
|
298
312
|
if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
|
|
299
313
|
errors.push("rust-hybrid release-candidate lane requires candidate { buildScript, checkScript, artifactRoot }");
|
|
300
314
|
}
|
|
315
|
+
|
|
316
|
+
if (manifest.ownership !== undefined) {
|
|
317
|
+
if (!manifest.ownership || typeof manifest.ownership !== "object" || Array.isArray(manifest.ownership)
|
|
318
|
+
|| Object.keys(manifest.ownership).some((key) => key !== "roots")) {
|
|
319
|
+
errors.push("ownership must be an object containing only roots");
|
|
320
|
+
} else if (!Array.isArray(manifest.ownership.roots) || manifest.ownership.roots.length === 0
|
|
321
|
+
|| manifest.ownership.roots.some((root) => typeof root !== "string" || (root !== "." && !/^(?!.*\.\.)(?!.*\\)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(root)) || root.startsWith("/"))) {
|
|
322
|
+
errors.push("ownership.roots must contain safe, non-empty repository-relative paths");
|
|
323
|
+
} else if (new Set(manifest.ownership.roots).size !== manifest.ownership.roots.length) {
|
|
324
|
+
errors.push("ownership.roots must not contain duplicates");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
301
327
|
if (profile !== "rust-hybrid" && candidate !== undefined) {
|
|
302
328
|
errors.push("candidate is only valid for profile 'rust-hybrid'");
|
|
303
329
|
}
|
|
@@ -464,6 +490,15 @@ const ARTIFACT_SIGNING_CLIENT_SHA256 = "74BD7D27E6CE1051409C38D9B46BC8DF0400ECD6
|
|
|
464
490
|
const WINDOWS_SDK_BUILD_TOOLS_VERSION = "10.0.26100.4188";
|
|
465
491
|
const WINDOWS_SDK_BUILD_TOOLS_SHA256 = "180DEB372659029864C10A0C04787833234D64AACD1D2C0661D2C00295D8E022";
|
|
466
492
|
|
|
493
|
+
// Signing and publication credentials must be reachable behind distinct
|
|
494
|
+
// protected environments, so a job that may sign cannot also publish. Products
|
|
495
|
+
// opt in per environment; absent configuration keeps the single `release`
|
|
496
|
+
// environment they use today, so enabling the split cannot strand secrets.
|
|
497
|
+
function releaseEnvironment(manifest, role) {
|
|
498
|
+
const configured = manifest?.candidate?.releaseChain?.environments?.[role];
|
|
499
|
+
return typeof configured === "string" && configured.length > 0 ? configured : "release";
|
|
500
|
+
}
|
|
501
|
+
|
|
467
502
|
function windowsSigningJob(manifest, pm, artifactPrefix) {
|
|
468
503
|
if (manifest.candidate?.signWindows !== true) return "";
|
|
469
504
|
const nodeVersion = String(manifest.matrix.node[0]);
|
|
@@ -484,7 +519,7 @@ function windowsSigningJob(manifest, pm, artifactPrefix) {
|
|
|
484
519
|
windows-sign:
|
|
485
520
|
needs: [admission, candidate]
|
|
486
521
|
if: \${{ needs.admission.outputs.signed_qualification == 'true' }}
|
|
487
|
-
environment:
|
|
522
|
+
environment: ${releaseEnvironment(manifest, "signing")}
|
|
488
523
|
strategy:
|
|
489
524
|
fail-fast: false
|
|
490
525
|
matrix:
|
|
@@ -497,6 +532,7 @@ ${signingMatrix(manifest, "windows")}
|
|
|
497
532
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
498
533
|
with:
|
|
499
534
|
ref: \${{ needs.admission.outputs.source_revision }}
|
|
535
|
+
fetch-depth: 0
|
|
500
536
|
persist-credentials: false
|
|
501
537
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
502
538
|
with:
|
|
@@ -612,7 +648,7 @@ function macosSigningJob(manifest, pm, artifactPrefix) {
|
|
|
612
648
|
macos-sign:
|
|
613
649
|
needs: [admission, candidate]
|
|
614
650
|
if: \${{ needs.admission.outputs.signed_qualification == 'true' }}
|
|
615
|
-
environment:
|
|
651
|
+
environment: ${releaseEnvironment(manifest, "signing")}
|
|
616
652
|
strategy:
|
|
617
653
|
fail-fast: false
|
|
618
654
|
matrix:
|
|
@@ -624,6 +660,7 @@ ${signingMatrix(manifest, "macos")}
|
|
|
624
660
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
625
661
|
with:
|
|
626
662
|
ref: \${{ needs.admission.outputs.source_revision }}
|
|
663
|
+
fetch-depth: 0
|
|
627
664
|
persist-credentials: false
|
|
628
665
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
629
666
|
with:
|
|
@@ -789,12 +826,13 @@ function releaseChainJobs(manifest, pm, artifactPrefix) {
|
|
|
789
826
|
installed-qualification:
|
|
790
827
|
needs: [admission, candidate, windows-sign]
|
|
791
828
|
if: \${{ always() && needs.admission.outputs.signed_qualification == 'true' && needs.candidate.result == 'success' }}
|
|
792
|
-
environment:
|
|
829
|
+
environment: ${releaseEnvironment(manifest, "qualification")}
|
|
793
830
|
runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
|
|
794
831
|
steps:
|
|
795
832
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
796
833
|
with:
|
|
797
834
|
ref: \${{ needs.admission.outputs.source_revision }}
|
|
835
|
+
fetch-depth: 0
|
|
798
836
|
persist-credentials: false
|
|
799
837
|
- name: Fail closed when protected finalization is absent
|
|
800
838
|
if: \${{ needs.windows-sign.result != 'success' }}
|
|
@@ -862,7 +900,7 @@ ${runIdentityEnv}
|
|
|
862
900
|
publish:
|
|
863
901
|
needs: [admission, installed-qualification${macosDependency}]
|
|
864
902
|
if: \${{ needs.admission.outputs.publish == 'true' && needs.installed-qualification.result == 'success'${macosSuccess} }}
|
|
865
|
-
environment:
|
|
903
|
+
environment: ${releaseEnvironment(manifest, "publication")}
|
|
866
904
|
runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
|
|
867
905
|
permissions:
|
|
868
906
|
contents: write
|
|
@@ -870,6 +908,7 @@ ${runIdentityEnv}
|
|
|
870
908
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
871
909
|
with:
|
|
872
910
|
ref: \${{ needs.admission.outputs.source_revision }}
|
|
911
|
+
fetch-depth: 0
|
|
873
912
|
persist-credentials: false
|
|
874
913
|
- name: Download same-run finalized Windows release
|
|
875
914
|
uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
|
|
@@ -950,7 +989,22 @@ export function deriveProjectName(repoPath) {
|
|
|
950
989
|
* Build the flat placeholder -> value map used by render.mjs, from a
|
|
951
990
|
* validated manifest plus the target repo's derived project name.
|
|
952
991
|
*/
|
|
953
|
-
|
|
992
|
+
/** Every managed public workflow runs ownership scanning; repository metadata cannot opt it out. */
|
|
993
|
+
export function ownershipStep(manifest, lane = "ci") {
|
|
994
|
+
const { name } = parsePackageManager(manifest.packageManager);
|
|
995
|
+
const command = name === "pnpm" ? "pnpm exec right-release ownership --app ."
|
|
996
|
+
: name === "npm" ? "npm exec -- right-release ownership --app ."
|
|
997
|
+
: "yarn exec right-release ownership --app .";
|
|
998
|
+
// Only release-candidate dispatches declare source_revision. Referencing
|
|
999
|
+
// inputs.source_revision from other lanes makes actionlint reject their
|
|
1000
|
+
// generated workflows, even when guarded by an event-name expression.
|
|
1001
|
+
const sourceRevision = lane === "release-candidate"
|
|
1002
|
+
? "\${{ inputs.source_revision }}"
|
|
1003
|
+
: "\${{ github.sha }}";
|
|
1004
|
+
return `\n - name: RightKit ownership (protected registry roots)\n shell: bash\n run: test "$(git rev-parse HEAD)" = "${sourceRevision}" && ${command}`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export function buildRenderContext(manifest, repoPath, lane = "ci") {
|
|
954
1008
|
const pm = parsePackageManager(manifest.packageManager);
|
|
955
1009
|
const projectName = deriveProjectName(repoPath);
|
|
956
1010
|
const artifactPrefix = projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
|
@@ -976,6 +1030,7 @@ export function buildRenderContext(manifest, repoPath) {
|
|
|
976
1030
|
WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
|
|
977
1031
|
MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
|
|
978
1032
|
RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
|
|
1033
|
+
OWNERSHIP_STEP: ownershipStep(manifest, lane),
|
|
979
1034
|
};
|
|
980
1035
|
|
|
981
1036
|
// Every key below is populated ONLY when its source manifest section is
|
package/manifest.schema.json
CHANGED
|
@@ -84,6 +84,19 @@
|
|
|
84
84
|
"type": "string",
|
|
85
85
|
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
|
86
86
|
}
|
|
87
|
+
,"ownership": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"required": ["roots"],
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"properties": {
|
|
92
|
+
"roots": {
|
|
93
|
+
"type": "array",
|
|
94
|
+
"minItems": 1,
|
|
95
|
+
"uniqueItems": true,
|
|
96
|
+
"items": { "type": "string", "pattern": "^(?:\\.|(?!.*\\.\\.)(?!.*\\\\)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*)$" }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
87
100
|
,"profile": { "enum": ["node", "rust-hybrid"] },
|
|
88
101
|
"rustCache": {
|
|
89
102
|
"type": "object",
|
package/manifest.test.mjs
CHANGED
|
@@ -22,6 +22,13 @@ test("accepts a minimal valid manifest", () => {
|
|
|
22
22
|
assert.deepEqual(errors, []);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
test("ownership scan registration is explicit and supports nested package roots", () => {
|
|
26
|
+
assert.equal(validateManifest({ ...VALID_MINIMAL, ownership: { roots: ["engine", "apps/client"] } }).valid, true);
|
|
27
|
+
for (const roots of [[], ["../outside"], ["src\\escape"], [""], ["engine", "engine"]]) {
|
|
28
|
+
assert.equal(validateManifest({ ...VALID_MINIMAL, ownership: { roots } }).valid, false, JSON.stringify(roots));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
25
32
|
test("rust-hybrid supports ci plus a configured unsigned release-candidate lane", () => {
|
|
26
33
|
const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
|
|
27
34
|
assert.equal(validateManifest(valid).valid, true);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/** Normalize HTTPS, SSH, and SCP GitHub remotes to one immutable repository key. */
|
|
4
|
+
export function normalizeRepositoryIdentity(value) {
|
|
5
|
+
if (typeof value !== "string" || !value.trim()) throw new Error("ownership registration requires a repository identity");
|
|
6
|
+
let text = value.trim();
|
|
7
|
+
const scp = text.match(/^(?:[^@/]+@)?([^:]+):([^/]+\/[^/]+)$/);
|
|
8
|
+
if (scp) text = `https://${scp[1]}/${scp[2]}`;
|
|
9
|
+
let url;
|
|
10
|
+
try { url = new URL(text); } catch { throw new Error("ownership repository identity must be a Git remote URL"); }
|
|
11
|
+
if (!["https:", "ssh:"].includes(url.protocol) || (url.username && !(url.protocol === "ssh:" && url.username === "git")) || url.password || url.search || url.hash) throw new Error("unsupported or credential-bearing ownership repository identity");
|
|
12
|
+
const host = url.hostname.toLowerCase();
|
|
13
|
+
const parts = url.pathname.replace(/\.git$/i, "").split("/").filter(Boolean);
|
|
14
|
+
if (host !== "github.com" || parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9_.-]+$/.test(part))) throw new Error("ownership repository identity must be owner/repository on github.com");
|
|
15
|
+
return `https://${host}/${parts[0].toLowerCase()}/${parts[1].toLowerCase()}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve immutable OS-owned roots for one checkout; repo declarations can only narrow reporting, never authority. */
|
|
19
|
+
export function protectedOwnershipRoots(trust, { repositoryIdentity, scanRoot, repoRoot }) {
|
|
20
|
+
if (!trust || !Array.isArray(trust.repositories)) throw new Error("protected ownership repository registry is missing");
|
|
21
|
+
const wanted = normalizeRepositoryIdentity(repositoryIdentity);
|
|
22
|
+
const matches = trust.repositories.filter((entry) => normalizeRepositoryIdentity(entry.repositoryIdentity) === wanted);
|
|
23
|
+
if (matches.length !== 1) throw new Error(matches.length ? "protected ownership registry has duplicate repository identities" : `repository is not registered for ownership enforcement: ${wanted}`);
|
|
24
|
+
const entry = matches[0];
|
|
25
|
+
if (!["public", "private"].includes(entry.visibility) || !Array.isArray(entry.roots) || !entry.roots.length) throw new Error("protected ownership registration is malformed");
|
|
26
|
+
const roots = entry.roots.map((root) => {
|
|
27
|
+
if (typeof root !== "string" || !root || root.includes("\\") || root.startsWith("/") || (root !== "." && root.split("/").some((p) => !p || p === "." || p === "..")) || /[*?]/.test(root)) throw new Error("protected ownership roots must be exact relative paths");
|
|
28
|
+
return root;
|
|
29
|
+
});
|
|
30
|
+
const relative = path.relative(repoRoot, scanRoot).replaceAll("\\", "/") || ".";
|
|
31
|
+
if (!roots.includes(relative)) throw new Error(`scan root is not registered for ${wanted}: ${relative}`);
|
|
32
|
+
return { repositoryIdentity: wanted, visibility: entry.visibility, roots };
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { normalizeRepositoryIdentity, protectedOwnershipRoots } from "./ownership-registration.mjs";
|
|
4
|
+
|
|
5
|
+
test("repository identity normalization collapses HTTPS, SSH, and SCP GitHub remotes", () => {
|
|
6
|
+
const expected = "https://github.com/bogusyogi/coderight";
|
|
7
|
+
for (const remote of ["https://github.com/bogusyogi/CodeRight.git", "ssh://git@github.com/bogusyogi/coderight.git", "git@github.com:bogusyogi/coderight.git"]) {
|
|
8
|
+
assert.equal(normalizeRepositoryIdentity(remote), expected);
|
|
9
|
+
}
|
|
10
|
+
assert.throws(() => normalizeRepositoryIdentity("https://user:secret@github.com/a/b.git"), /credential-bearing/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("protected registry is authoritative for exact nested roots and visibility", () => {
|
|
14
|
+
const trust = { repositories: [{ repositoryIdentity: "https://github.com/bogusyogi/coderight", roots: ["engine", "apps/ui"], visibility: "private" }] };
|
|
15
|
+
assert.deepEqual(protectedOwnershipRoots(trust, { repositoryIdentity: "git@github.com:bogusyogi/coderight.git", scanRoot: "C:/repo/engine", repoRoot: "C:/repo" }), {
|
|
16
|
+
repositoryIdentity: "https://github.com/bogusyogi/coderight", visibility: "private", roots: ["engine", "apps/ui"],
|
|
17
|
+
});
|
|
18
|
+
assert.throws(() => protectedOwnershipRoots(trust, { repositoryIdentity: "https://github.com/bogusyogi/coderight", scanRoot: "C:/repo/src", repoRoot: "C:/repo" }), /not registered/);
|
|
19
|
+
assert.throws(() => protectedOwnershipRoots({ repositories: [] }, { repositoryIdentity: "https://github.com/a/b", scanRoot: ".", repoRoot: "." }), /not registered/);
|
|
20
|
+
});
|
package/pack-smoke.test.mjs
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// publish and runs the extracted bin with `node` — `node --test` run from
|
|
4
4
|
// the source tree never notices a missing directory because the source
|
|
5
5
|
// tree has every file regardless of what `files` declares. This test packs
|
|
6
|
-
// for real (
|
|
6
|
+
// for real (pnpm pack), extracts to a temp dir, and executes the built bin's
|
|
7
7
|
// --help and --version from inside that extracted tree only.
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,17 +24,17 @@ function run(command, args, options) {
|
|
|
24
24
|
test("packaged tarball: right-git --help and --version run clean from the published files only", () => {
|
|
25
25
|
const workDir = mkdtempSync(path.join(os.tmpdir(), "rightkit-git-pack-"));
|
|
26
26
|
try {
|
|
27
|
-
// shell:true
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
// shell:true resolves pnpm.cmd on Windows; args are fixed literals.
|
|
28
|
+
const packResult = run("pnpm", ["pack", "--pack-destination", workDir], { cwd: packageRoot, shell: true });
|
|
29
|
+
assert.equal(packResult.status, 0, `pnpm pack failed: ${packResult.stderr}`);
|
|
30
|
+
const archive = readdirSync(workDir).find((name) => name.endsWith(".tgz"));
|
|
31
|
+
assert.ok(archive, `pnpm pack did not create a tarball: ${packResult.stdout}`);
|
|
32
32
|
|
|
33
33
|
// Run tar FROM the temp dir on the bare filename so no absolute "C:\..."
|
|
34
34
|
// path reaches tar — that colon is the only reason GNU tar's --force-local
|
|
35
35
|
// was ever needed, and avoiding it keeps this portable to bsdtar (the
|
|
36
36
|
// Windows built-in) and any CI host, not just a GNU-tar toolchain.
|
|
37
|
-
const extractResult = run("tar", ["-xzf",
|
|
37
|
+
const extractResult = run("tar", ["-xzf", archive], { cwd: workDir });
|
|
38
38
|
assert.equal(extractResult.status, 0, `tar extract failed: ${extractResult.stderr}`);
|
|
39
39
|
// npm tarballs always nest content under a top-level "package/" directory.
|
|
40
40
|
const extractedPackageDir = path.join(workDir, "package");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/git",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.26",
|
|
4
4
|
"description": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -15,9 +15,6 @@
|
|
|
15
15
|
"*.json"
|
|
16
16
|
],
|
|
17
17
|
"sideEffects": false,
|
|
18
|
-
"scripts": {
|
|
19
|
-
"test": "node --test *.test.mjs"
|
|
20
|
-
},
|
|
21
18
|
"publishConfig": {
|
|
22
19
|
"registry": "https://registry.npmjs.org/",
|
|
23
20
|
"access": "public"
|
|
@@ -27,5 +24,7 @@
|
|
|
27
24
|
"url": "git+https://github.com/bogusyogi/claude.git",
|
|
28
25
|
"directory": "tools/rightkit/packages/git"
|
|
29
26
|
},
|
|
30
|
-
"
|
|
31
|
-
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test *.test.mjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/render.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { renderTemplate, MANAGED_MARKER } from "./render.mjs";
|
|
7
|
-
import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES } from "./manifest.mjs";
|
|
7
|
+
import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES, validateManifest } from "./manifest.mjs";
|
|
8
8
|
|
|
9
9
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
|
|
10
10
|
const templatesDir = path.join(packageRoot, "templates");
|
|
@@ -24,12 +24,11 @@ const FULL_MANIFEST = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
function renderAllLanes(manifest, repoPath) {
|
|
27
|
-
const context = buildRenderContext(manifest, repoPath);
|
|
28
27
|
const rendered = {};
|
|
29
28
|
for (const lane of manifest.lanes) {
|
|
30
29
|
const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
|
|
31
30
|
const templateText = fs.readFileSync(templatePath, "utf8");
|
|
32
|
-
rendered[lane] = renderTemplate(templateText,
|
|
31
|
+
rendered[lane] = renderTemplate(templateText, buildRenderContext(manifest, repoPath, lane));
|
|
33
32
|
}
|
|
34
33
|
return rendered;
|
|
35
34
|
}
|
|
@@ -60,6 +59,7 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
|
|
|
60
59
|
assert.match(content, /changed=true[\s\S]*\^0\{40\}\$[\s\S]*git cat-file -e[\s\S]*git diff --name-only --diff-filter=ACDMRTUXB/);
|
|
61
60
|
assert.match(content, /--diff-filter=ACDMRTUXB/, "deleted Rust inputs must trigger Rust setup");
|
|
62
61
|
assert.match(content, /\*\.rs\|Cargo\.toml[\s\S]*scripts\/gate\.sh/);
|
|
62
|
+
assert.doesNotMatch(content, /\|build\.rs\||\|\*\/build\.rs\|/);
|
|
63
63
|
assert.match(content, /\.rightgit\.json\|\.github\/workflows\/\*\.yml\|\.github\/workflows\/\*\.yaml/);
|
|
64
64
|
assert.equal((content.match(/if: steps\.rust-changes\.outputs\.required == 'true'/g) ?? []).length, 3);
|
|
65
65
|
assert.match(content, /RIGHT_GIT_RUST_CHANGED: \$\{\{ steps\.rust-changes\.outputs\.changed \}\}/);
|
|
@@ -73,12 +73,36 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
|
|
|
73
73
|
assert.doesNotMatch(content, /ubuntu-latest|macos-latest/);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test("every generated workflow profile runs ownership scanning independent of optional repository roots", () => {
|
|
77
|
+
const roots = ["engine", "apps/client"];
|
|
78
|
+
const manifest = { ...FULL_MANIFEST, ownership: { roots } };
|
|
79
|
+
const rendered = renderAllLanes(manifest, "C:/repos/coderight");
|
|
80
|
+
for (const [lane, content] of Object.entries(rendered)) {
|
|
81
|
+
assert.match(content, /right-release ownership --app \./, lane);
|
|
82
|
+
assert.match(content, /protected registry roots/, lane);
|
|
83
|
+
}
|
|
84
|
+
const rust = renderTemplate(fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8"), buildRenderContext({ ...manifest, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } }, "C:/repos/coderight"));
|
|
85
|
+
assert.match(rust, /RightKit ownership \(protected registry roots\)/);
|
|
86
|
+
assert.match(rust, /pnpm exec right-release ownership --app \./);
|
|
87
|
+
const withoutRoots = renderAllLanes(FULL_MANIFEST, "C:/repos/widgets");
|
|
88
|
+
for (const [lane, content] of Object.entries(withoutRoots)) assert.match(content, /right-release ownership --app \./, lane);
|
|
89
|
+
});
|
|
90
|
+
|
|
76
91
|
test("ordinary CI templates cancel superseded runs on the same ref", () => {
|
|
77
92
|
const generic = renderAllLanes(FULL_MANIFEST, "/repos/widgets").ci;
|
|
78
93
|
assert.match(generic, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}/);
|
|
79
94
|
assert.match(generic, /cancel-in-progress: true/);
|
|
80
95
|
});
|
|
81
96
|
|
|
97
|
+
test("ownership revision expression matches each lane trigger contract", () => {
|
|
98
|
+
const rendered = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
|
|
99
|
+
assert.match(rendered.ci, /git rev-parse HEAD\)" = "\$\{\{ github\.sha \}\}/);
|
|
100
|
+
assert.doesNotMatch(rendered.ci, /inputs\.source_revision/);
|
|
101
|
+
assert.match(rendered["publish-package-managers"], /git rev-parse HEAD\)" = "\$\{\{ github\.sha \}\}/);
|
|
102
|
+
assert.doesNotMatch(rendered["publish-package-managers"], /inputs\.source_revision/);
|
|
103
|
+
assert.match(rendered["release-candidate"], /git rev-parse HEAD\)" = "\$\{\{ inputs\.source_revision \}\}/);
|
|
104
|
+
});
|
|
105
|
+
|
|
82
106
|
test("rust-hybrid ci preserves a declared macOS-only lane", () => {
|
|
83
107
|
const manifest = {
|
|
84
108
|
...FULL_MANIFEST,
|
|
@@ -180,7 +204,7 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
|
|
|
180
204
|
};
|
|
181
205
|
const content = renderTemplate(
|
|
182
206
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
183
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
207
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
184
208
|
);
|
|
185
209
|
assert.match(content, /environment: release/);
|
|
186
210
|
// Tag-last contract: signing is gated on the admitted, same-run
|
|
@@ -196,8 +220,8 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
|
|
|
196
220
|
// Every downstream job (admission, candidate, and windows-sign here) checks
|
|
197
221
|
// out the exact admitted revision by SHA, never by resolving HEAD after a
|
|
198
222
|
// ref-triggered checkout.
|
|
199
|
-
assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/);
|
|
200
|
-
assert.
|
|
223
|
+
assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
|
|
224
|
+
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
|
|
201
225
|
assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
|
|
202
226
|
assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
|
|
203
227
|
assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
|
|
@@ -225,7 +249,7 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
|
|
|
225
249
|
};
|
|
226
250
|
const content = renderTemplate(
|
|
227
251
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
228
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
252
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
229
253
|
);
|
|
230
254
|
assert.match(content, /macos-sign:/);
|
|
231
255
|
assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
|
|
@@ -235,8 +259,8 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
|
|
|
235
259
|
assert.match(content, /temporary keychain/i);
|
|
236
260
|
assert.match(content, /right-release build --platform mac --skip-checks/);
|
|
237
261
|
assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
|
|
238
|
-
assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/);
|
|
239
|
-
assert.
|
|
262
|
+
assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
|
|
263
|
+
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
|
|
240
264
|
assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
|
|
241
265
|
assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
|
|
242
266
|
assert.match(content, /security delete-keychain/);
|
|
@@ -272,7 +296,7 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
|
|
|
272
296
|
};
|
|
273
297
|
const content = renderTemplate(
|
|
274
298
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
275
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
299
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
276
300
|
);
|
|
277
301
|
for (const [runner, triple] of [["windows-2025", "x86_64-pc-windows-msvc"], ["windows-11-arm", "aarch64-pc-windows-msvc"], ["macos-15", "aarch64-apple-darwin"], ["macos-15-intel", "x86_64-apple-darwin"]]) {
|
|
278
302
|
assert.match(content, new RegExp(`os: ${JSON.stringify(runner).replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`));
|
|
@@ -323,7 +347,7 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
|
|
|
323
347
|
};
|
|
324
348
|
const content = renderTemplate(
|
|
325
349
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
326
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
350
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
327
351
|
);
|
|
328
352
|
assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
|
|
329
353
|
assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
|
|
@@ -355,18 +379,17 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
|
|
|
355
379
|
assert.match(content, /Verify aggregated release evidence[\s\S]*?pnpm run release:verify-evidence/);
|
|
356
380
|
assert.match(content, /pnpm run release:publish:qualified/);
|
|
357
381
|
assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
assert.doesNotMatch(content, /git rev-parse HEAD/);
|
|
382
|
+
// Ownership scan binds to the source revision input before admission emits
|
|
383
|
+
// that same revision for every downstream job.
|
|
384
|
+
assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
|
|
362
385
|
assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
|
|
363
|
-
// windows-sign, macos-sign, installed-qualification
|
|
364
|
-
// the admitted revision
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
assert.equal((content.match(/ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/g) ?? []).length,
|
|
369
|
-
assert.
|
|
386
|
+
// candidate, windows-sign, macos-sign, installed-qualification and publish all
|
|
387
|
+
// check out the admitted revision with full history; admission checks out
|
|
388
|
+
// ${{ inputs.source_revision }} (it IS the admission job — there is no
|
|
389
|
+
// needs.admission yet). Full history is load-bearing, not cosmetic: see
|
|
390
|
+
// "every job checking out an admitted revision fetches full history".
|
|
391
|
+
assert.equal((content.match(/ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/g) ?? []).length, 5, "candidate, windows-sign, macos-sign, installed-qualification & publish should all pin the admitted revision with full history");
|
|
392
|
+
assert.doesNotMatch(content, /ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/, "no admitted-revision checkout may be shallow");
|
|
370
393
|
assert.match(content, /ref: \$\{\{ inputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/, "admission must check out the exact dispatch-supplied revision");
|
|
371
394
|
assert.doesNotMatch(content.slice(0, content.indexOf(" windows-sign:")), /secrets\.|id-token: write|contents: write/);
|
|
372
395
|
// Downstream download-artifact names must match exactly what windows-sign/
|
|
@@ -416,7 +439,7 @@ test("every 'run <script>' in a full release-chain workflow names a script the m
|
|
|
416
439
|
};
|
|
417
440
|
const content = renderTemplate(
|
|
418
441
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
419
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
442
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
420
443
|
);
|
|
421
444
|
const declaredScripts = new Set([
|
|
422
445
|
manifest.candidate.buildScript,
|
|
@@ -477,7 +500,7 @@ test("release chain: installed-qualification has a stage summary and publish dow
|
|
|
477
500
|
};
|
|
478
501
|
const content = renderTemplate(
|
|
479
502
|
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
480
|
-
buildRenderContext(manifest, "/repos/widgets"),
|
|
503
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
481
504
|
);
|
|
482
505
|
const installedQualificationJob = content.slice(
|
|
483
506
|
content.search(/^ {2}installed-qualification:\n {4}needs:/m),
|
|
@@ -637,3 +660,172 @@ test("renderTemplate normalizes an empty final release-chain fragment to one new
|
|
|
637
660
|
assert.equal(out, "jobs:\n");
|
|
638
661
|
assert.equal((out.match(/\n/g) ?? []).length, 1);
|
|
639
662
|
});
|
|
663
|
+
|
|
664
|
+
// Tag-last has no tag to check out, so every stage checks out one exact admitted
|
|
665
|
+
// commit SHA and HEAD is always detached. @rightkit/release only exempts a
|
|
666
|
+
// detached HEAD when it can prove the commit is reachable from the dispatched
|
|
667
|
+
// remote branch — and proving reachability needs history plus the
|
|
668
|
+
// remote-tracking ref. A default shallow checkout has neither, so the signing,
|
|
669
|
+
// qualification, and publish jobs would be refused. Every job that checks out an
|
|
670
|
+
// admitted revision must therefore ask for full history.
|
|
671
|
+
test("every job checking out an admitted revision fetches full history", () => {
|
|
672
|
+
const manifest = {
|
|
673
|
+
...FULL_MANIFEST,
|
|
674
|
+
lanes: ["ci", "release-candidate"],
|
|
675
|
+
profile: "rust-hybrid",
|
|
676
|
+
gate: { path: "scripts/gate.sh" },
|
|
677
|
+
candidate: {
|
|
678
|
+
buildScript: "ci:candidate",
|
|
679
|
+
checkScript: "ci:candidate-check",
|
|
680
|
+
admissionScript: "release:admission",
|
|
681
|
+
stageSummaryScript: "release:stage-summary",
|
|
682
|
+
artifactRoot: "widgets-unsigned",
|
|
683
|
+
os: ["windows-2025", "macos-15"],
|
|
684
|
+
signWindows: true,
|
|
685
|
+
signMacos: true,
|
|
686
|
+
releaseChain: {
|
|
687
|
+
evidenceVerificationScript: "release:verify:evidence",
|
|
688
|
+
windowsFinalizeScript: "release:finalize:win",
|
|
689
|
+
macosFinalizeScript: "release:finalize:mac",
|
|
690
|
+
installedQualificationScript: "release:qualify:installed",
|
|
691
|
+
publishScript: "release:publish:qualified",
|
|
692
|
+
qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
|
|
693
|
+
publishSecrets: ["CLOUDFLARE_API_TOKEN"],
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
|
|
697
|
+
templateVersion: "1.9.0",
|
|
698
|
+
};
|
|
699
|
+
const content = renderTemplate(
|
|
700
|
+
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
701
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
702
|
+
);
|
|
703
|
+
const checkouts = content
|
|
704
|
+
.split(/^ {6}- uses: actions\/checkout@/m)
|
|
705
|
+
.slice(1)
|
|
706
|
+
.map((step) => step.split(/^ {6}- /m)[0]);
|
|
707
|
+
assert.ok(checkouts.length >= 6, `expected every stage to check out; found ${checkouts.length}`);
|
|
708
|
+
for (const step of checkouts) {
|
|
709
|
+
if (!/ref: \$\{\{ (needs\.admission\.outputs\.source_revision|inputs\.source_revision) \}\}/.test(step)) continue;
|
|
710
|
+
assert.match(step, /fetch-depth: 0/, `a checkout of an admitted revision must fetch full history:\n${step}`);
|
|
711
|
+
}
|
|
712
|
+
// The four downstream stages specifically — these were the ones shipping shallow.
|
|
713
|
+
for (const job of ["windows-sign", "macos-sign", "installed-qualification", "publish"]) {
|
|
714
|
+
const body = content.split(new RegExp(`^ ${job}:$`, "m"))[1] ?? "";
|
|
715
|
+
const checkout = body.split(/^ {6}- uses: actions\/checkout@/m)[1]?.split(/^ {6}- /m)[0] ?? "";
|
|
716
|
+
assert.match(checkout, /fetch-depth: 0/, `${job} must check out with full history`);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Rule 3 (release workflows have a dry run): a boolean dry_run input, runnable
|
|
721
|
+
// from a branch, must thread through admission so packaging/signing/qualification
|
|
722
|
+
// run while publication is skipped and artifacts carry a dry-run suffix.
|
|
723
|
+
test("release-candidate template exposes a dry_run input threaded through admission", () => {
|
|
724
|
+
const manifest = {
|
|
725
|
+
...FULL_MANIFEST,
|
|
726
|
+
lanes: ["ci", "release-candidate"],
|
|
727
|
+
profile: "rust-hybrid",
|
|
728
|
+
gate: { path: "scripts/gate.sh" },
|
|
729
|
+
candidate: {
|
|
730
|
+
buildScript: "ci:candidate",
|
|
731
|
+
checkScript: "ci:candidate-check",
|
|
732
|
+
admissionScript: "release:admission",
|
|
733
|
+
stageSummaryScript: "release:stage-summary",
|
|
734
|
+
artifactRoot: "widgets-unsigned",
|
|
735
|
+
os: ["windows-2025", "macos-15"],
|
|
736
|
+
signWindows: true,
|
|
737
|
+
signMacos: true,
|
|
738
|
+
},
|
|
739
|
+
matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
|
|
740
|
+
templateVersion: "1.9.0",
|
|
741
|
+
};
|
|
742
|
+
const content = renderTemplate(
|
|
743
|
+
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
744
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
745
|
+
);
|
|
746
|
+
assert.match(content, /^ dry_run:\n description: .*\n required: false\n type: boolean\n default: false$/m);
|
|
747
|
+
assert.match(content, /dry_run: \$\{\{ steps\.admit\.outputs\.dry_run \}\}/);
|
|
748
|
+
assert.match(content, /artifact_suffix: \$\{\{ steps\.admit\.outputs\.artifact_suffix \}\}/);
|
|
749
|
+
assert.match(content, /RIGHT_GIT_DRY_RUN: \$\{\{ inputs\.dry_run \}\}/);
|
|
750
|
+
// The envelope only pins refs/heads/main and first-attempt for a real dispatch.
|
|
751
|
+
assert.match(content, /if \[\[ "\$\{\{ inputs\.dry_run \}\}" == "true" \]\]; then\n \[\[ "\$GITHUB_REF" == refs\/heads\/\* \]\]/);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Correction 11: Windows signing, macOS signing, installed qualification and
|
|
755
|
+
// publication all shared one `release` environment, so no signing/publication
|
|
756
|
+
// grant separation existed. The split is opt-in per product because the secrets
|
|
757
|
+
// live in the existing environment until an operator moves them.
|
|
758
|
+
function environmentManifest(environments) {
|
|
759
|
+
const manifest = {
|
|
760
|
+
schemaVersion: 1,
|
|
761
|
+
compile: "github-actions-only",
|
|
762
|
+
lanes: ["ci", "release-candidate"],
|
|
763
|
+
packageManager: "pnpm@11.24.0",
|
|
764
|
+
profile: "rust-hybrid",
|
|
765
|
+
gate: { path: "scripts/gate.sh" },
|
|
766
|
+
candidate: {
|
|
767
|
+
buildScript: "ci:candidate",
|
|
768
|
+
checkScript: "ci:candidate-check",
|
|
769
|
+
admissionScript: "release:admit",
|
|
770
|
+
stageSummaryScript: "release:stage-summary",
|
|
771
|
+
artifactRoot: "widgets-unsigned",
|
|
772
|
+
os: ["windows-2025", "macos-15"],
|
|
773
|
+
targets: [
|
|
774
|
+
{ os: "windows-2025", platform: "windows", architecture: "x86_64" },
|
|
775
|
+
{ os: "macos-15", platform: "macos", architecture: "arm64" },
|
|
776
|
+
],
|
|
777
|
+
signWindows: true,
|
|
778
|
+
signMacos: true,
|
|
779
|
+
releaseChain: {
|
|
780
|
+
windowsFinalizeScript: "release:finalize-windows",
|
|
781
|
+
macosFinalizeScript: "release:finalize-macos",
|
|
782
|
+
installedQualificationScript: "release:qualify-installed",
|
|
783
|
+
publishScript: "release:publish-qualified",
|
|
784
|
+
qualificationRunnerVariable: "WIDGETS_QUALIFICATION_RUNNER",
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
matrix: { os: ["ubuntu-24.04", "windows-2025"], node: ["22.23.2"] },
|
|
788
|
+
templateVersion: "1.8.0",
|
|
789
|
+
};
|
|
790
|
+
if (environments) manifest.candidate.releaseChain.environments = environments;
|
|
791
|
+
return manifest;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function renderRelease(manifest) {
|
|
795
|
+
return renderTemplate(
|
|
796
|
+
fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
|
|
797
|
+
buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
test("release environments default to the single release environment", () => {
|
|
802
|
+
const content = renderRelease(environmentManifest(null));
|
|
803
|
+
assert.ok((content.match(/environment: release$/gm) ?? []).length >= 2);
|
|
804
|
+
assert.doesNotMatch(content, /environment: signing-authorized/);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("signing and publication split into distinct protected environments", () => {
|
|
808
|
+
const content = renderRelease(environmentManifest({
|
|
809
|
+
signing: "signing-authorized",
|
|
810
|
+
publication: "release-authorized",
|
|
811
|
+
qualification: "qualification",
|
|
812
|
+
}));
|
|
813
|
+
assert.equal((content.match(/environment: signing-authorized$/gm) ?? []).length, 2);
|
|
814
|
+
assert.equal((content.match(/environment: release-authorized$/gm) ?? []).length, 1);
|
|
815
|
+
// A job that may sign must never also be the job that publishes.
|
|
816
|
+
assert.doesNotMatch(content, /environment: release$/m);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("an unsafe or unknown release environment is rejected", () => {
|
|
820
|
+
assert.equal(validateManifest(environmentManifest({ signing: "bad name!" })).valid, false);
|
|
821
|
+
assert.equal(validateManifest(environmentManifest({ promotion: "x" })).valid, false);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("ci.yml ownership check cannot be removed by deleting optional repo metadata", () => {
|
|
825
|
+
const manifest = { ...structuredClone(FULL_MANIFEST), ownership: { roots: [".", "engine"] } };
|
|
826
|
+
const template = fs.readFileSync(path.join(templatesDir, "ci.yml"), "utf8");
|
|
827
|
+
const withStep = renderTemplate(template, buildRenderContext(manifest, "/repos/coderight"));
|
|
828
|
+
assert.match(withStep, /RightKit ownership \(protected registry roots\)/);
|
|
829
|
+
const without = renderTemplate(template, buildRenderContext(structuredClone(FULL_MANIFEST), "/repos/widgets"));
|
|
830
|
+
assert.match(without, /RightKit ownership \(protected registry roots\)/);
|
|
831
|
+
});
|
package/sync.mjs
CHANGED
|
@@ -31,8 +31,8 @@ function releaseChainDependencyError(repoPath, manifest) {
|
|
|
31
31
|
|
|
32
32
|
/** Render every lane declared in the manifest. Returns { lane, filename, content }[]. */
|
|
33
33
|
export function renderLanes(manifest, repoPath) {
|
|
34
|
-
const context = buildRenderContext(manifest, repoPath);
|
|
35
34
|
return manifest.lanes.map((lane) => {
|
|
35
|
+
const context = buildRenderContext(manifest, repoPath, lane);
|
|
36
36
|
const filename = LANE_FILENAMES[lane];
|
|
37
37
|
const templatePath = manifest.profile === "rust-hybrid"
|
|
38
38
|
? path.join(TEMPLATES_DIR, "profiles", "rust-hybrid", filename)
|
package/templates/ci.yml
CHANGED
|
@@ -45,7 +45,7 @@ jobs:
|
|
|
45
45
|
changed=false
|
|
46
46
|
while IFS= read -r changed_path; do
|
|
47
47
|
case "$changed_path" in
|
|
48
|
-
*.rs|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|
|
|
48
|
+
*.rs|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|rust-toolchain|*/rust-toolchain|rust-toolchain.toml|*/rust-toolchain.toml|.cargo/*|*/.cargo/*|.rightgit.json|.github/workflows/*.yml|.github/workflows/*.yaml|${GATE_PATH})
|
|
49
49
|
changed=true
|
|
50
50
|
break
|
|
51
51
|
;;
|
|
@@ -64,6 +64,8 @@ jobs:
|
|
|
64
64
|
node-version: ${{ matrix.node }}
|
|
65
65
|
package-manager-cache: false
|
|
66
66
|
${PACKAGE_MANAGER_SETUP}
|
|
67
|
+
- run: ${INSTALL_COMMAND}
|
|
68
|
+
${OWNERSHIP_STEP}
|
|
67
69
|
- uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
|
|
68
70
|
if: steps.rust-changes.outputs.required == 'true'
|
|
69
71
|
with:
|
|
@@ -23,6 +23,11 @@ on:
|
|
|
23
23
|
required: false
|
|
24
24
|
type: boolean
|
|
25
25
|
default: false
|
|
26
|
+
dry_run:
|
|
27
|
+
description: Rehearse packaging, signing & installed qualification from a branch with publication skipped
|
|
28
|
+
required: false
|
|
29
|
+
type: boolean
|
|
30
|
+
default: false
|
|
26
31
|
concurrency:
|
|
27
32
|
group: release-${{ inputs.version }}
|
|
28
33
|
cancel-in-progress: false
|
|
@@ -45,13 +50,20 @@ jobs:
|
|
|
45
50
|
source_revision: ${{ steps.admit.outputs.source_revision }}
|
|
46
51
|
signed_qualification: ${{ steps.admit.outputs.signed_qualification }}
|
|
47
52
|
publish: ${{ steps.admit.outputs.publish }}
|
|
53
|
+
dry_run: ${{ steps.admit.outputs.dry_run }}
|
|
54
|
+
artifact_suffix: ${{ steps.admit.outputs.artifact_suffix }}
|
|
48
55
|
steps:
|
|
49
56
|
- name: Validate immutable dispatch envelope
|
|
50
57
|
shell: bash
|
|
51
58
|
run: |
|
|
52
59
|
set -euo pipefail
|
|
53
|
-
[[ "$
|
|
54
|
-
|
|
60
|
+
if [[ "${{ inputs.dry_run }}" == "true" ]]; then
|
|
61
|
+
[[ "$GITHUB_REF" == refs/heads/* ]]
|
|
62
|
+
[[ "${{ inputs.publish }}" != "true" ]]
|
|
63
|
+
else
|
|
64
|
+
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
|
65
|
+
[[ "$GITHUB_RUN_ATTEMPT" == "1" ]]
|
|
66
|
+
fi
|
|
55
67
|
[[ "${{ inputs.version }}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
|
56
68
|
[[ "${{ inputs.source_revision }}" =~ ^[a-f0-9]{40}$ ]]
|
|
57
69
|
if [[ "${{ inputs.publish }}" == "true" ]]; then [[ "${{ inputs.signed_qualification }}" == "true" ]]; fi
|
|
@@ -66,6 +78,7 @@ jobs:
|
|
|
66
78
|
package-manager-cache: false
|
|
67
79
|
${PACKAGE_MANAGER_SETUP}
|
|
68
80
|
- run: ${INSTALL_COMMAND}
|
|
81
|
+
${OWNERSHIP_STEP}
|
|
69
82
|
- name: Admit exact main revision & version
|
|
70
83
|
id: admit
|
|
71
84
|
run: ${PM_RUN} ${RELEASE_ADMISSION_SCRIPT}
|
|
@@ -75,6 +88,7 @@ ${PACKAGE_MANAGER_SETUP}
|
|
|
75
88
|
RIGHT_GIT_SOURCE_REVISION: ${{ inputs.source_revision }}
|
|
76
89
|
RIGHT_GIT_SIGNED_QUALIFICATION: ${{ inputs.signed_qualification }}
|
|
77
90
|
RIGHT_GIT_PUBLISH: ${{ inputs.publish }}
|
|
91
|
+
RIGHT_GIT_DRY_RUN: ${{ inputs.dry_run }}
|
|
78
92
|
RIGHT_GIT_WORKFLOW_REF: ${{ github.ref }}
|
|
79
93
|
RIGHT_GIT_RUN_ATTEMPT: ${{ github.run_attempt }}
|
|
80
94
|
candidate:
|
|
@@ -40,6 +40,7 @@ jobs:
|
|
|
40
40
|
package-manager-cache: false
|
|
41
41
|
${PACKAGE_MANAGER_SETUP}
|
|
42
42
|
- run: ${INSTALL_COMMAND}
|
|
43
|
+
${OWNERSHIP_STEP}
|
|
43
44
|
- run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
|
|
44
45
|
- run: node scripts/release/check-release.mjs release/candidates/publish
|
|
45
46
|
- id: tarball
|
|
@@ -35,6 +35,7 @@ jobs:
|
|
|
35
35
|
package-manager-cache: false
|
|
36
36
|
${PACKAGE_MANAGER_SETUP}
|
|
37
37
|
- run: ${INSTALL_COMMAND}
|
|
38
|
+
${OWNERSHIP_STEP}
|
|
38
39
|
- run: node --test tests/release-candidate.test.mjs
|
|
39
40
|
- run: node scripts/release/build-candidate.mjs --platform current --out "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
|
|
40
41
|
- run: node scripts/release/check-release.mjs "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
|