@rightkit/git 0.2.22 → 0.2.24

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/manifest.test.mjs CHANGED
@@ -32,7 +32,14 @@ test("rust-hybrid supports ci plus a configured unsigned release-candidate lane"
32
32
  const candidate = {
33
33
  ...valid,
34
34
  lanes: ["ci", "release-candidate"],
35
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "legion-unsigned", os: ["ubuntu-latest"] },
35
+ candidate: {
36
+ buildScript: "ci:candidate",
37
+ checkScript: "ci:candidate-check",
38
+ admissionScript: "release:admit",
39
+ stageSummaryScript: "release:stage-summary",
40
+ artifactRoot: "legion-unsigned",
41
+ os: ["ubuntu-latest"],
42
+ },
36
43
  };
37
44
  assert.equal(validateManifest(candidate).valid, true);
38
45
  assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, signWindows: true } }).valid, false);
@@ -53,6 +60,7 @@ test("rust-hybrid supports ci plus a configured unsigned release-candidate lane"
53
60
  candidate: {
54
61
  ...windowsCandidate.candidate,
55
62
  releaseChain: {
63
+ evidenceVerificationScript: "release:verify-evidence",
56
64
  windowsFinalizeScript: "release:finalize:win",
57
65
  installedQualificationScript: "release:qualify:installed",
58
66
  publishScript: "release:publish:qualified",
@@ -68,6 +76,103 @@ test("rust-hybrid supports ci plus a configured unsigned release-candidate lane"
68
76
  assert.equal(validateManifest({ ...releaseChain, candidate: { ...releaseChain.candidate, releaseChain: { ...releaseChain.candidate.releaseChain, publishSecrets: ["TOKEN", "TOKEN"] } } }).valid, false);
69
77
  });
70
78
 
79
+ // Part B fix: admissionScript & stageSummaryScript were moved OUT of the
80
+ // optional candidate.releaseChain object and onto candidate directly,
81
+ // because the admission job and the candidate job's stage-summary
82
+ // init/finalize steps are unconditional parts of the rendered
83
+ // release-candidate template — present for every release-candidate lane,
84
+ // signed or not. This test proves each is independently required with a
85
+ // specific, actionable error, and that the full releaseChain contract
86
+ // (now minus those two promoted fields) is validated key-by-key.
87
+ test("rust-hybrid release-candidate requires admissionScript & stageSummaryScript independent of signing", () => {
88
+ const base = {
89
+ ...VALID_MINIMAL,
90
+ profile: "rust-hybrid",
91
+ gate: { path: "scripts/gate.sh" },
92
+ lanes: ["ci", "release-candidate"],
93
+ candidate: {
94
+ buildScript: "ci:candidate",
95
+ checkScript: "ci:candidate-check",
96
+ admissionScript: "release:admit",
97
+ stageSummaryScript: "release:stage-summary",
98
+ artifactRoot: "widgets-unsigned",
99
+ os: ["ubuntu-latest"],
100
+ },
101
+ };
102
+ assert.equal(validateManifest(base).valid, true, JSON.stringify(validateManifest(base).errors));
103
+
104
+ const { admissionScript, ...withoutAdmission } = base.candidate;
105
+ const missingAdmission = validateManifest({ ...base, candidate: withoutAdmission });
106
+ assert.equal(missingAdmission.valid, false);
107
+ assert.ok(missingAdmission.errors.includes("candidate.admissionScript must be one safe package script name"));
108
+
109
+ const { stageSummaryScript, ...withoutStageSummary } = base.candidate;
110
+ const missingStageSummary = validateManifest({ ...base, candidate: withoutStageSummary });
111
+ assert.equal(missingStageSummary.valid, false);
112
+ assert.ok(missingStageSummary.errors.includes("candidate.stageSummaryScript must be one safe package script name"));
113
+
114
+ // Neither field is accepted inside releaseChain any more.
115
+ const windows = {
116
+ ...base,
117
+ matrix: { ...base.matrix, os: ["ubuntu-latest", "windows-2025"] },
118
+ candidate: { ...base.candidate, os: ["windows-2025"], signWindows: true },
119
+ };
120
+ const misplaced = validateManifest({
121
+ ...windows,
122
+ candidate: {
123
+ ...windows.candidate,
124
+ releaseChain: {
125
+ admissionScript: "release:admit",
126
+ evidenceVerificationScript: "release:verify-evidence",
127
+ windowsFinalizeScript: "release:finalize:win",
128
+ installedQualificationScript: "release:qualify:installed",
129
+ publishScript: "release:publish:qualified",
130
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
131
+ },
132
+ },
133
+ });
134
+ assert.equal(misplaced.valid, false);
135
+ assert.ok(misplaced.errors.includes("unknown candidate.releaseChain field: admissionScript"));
136
+ });
137
+
138
+ // Part B fix: evidenceVerificationScript is the one new script that DOES
139
+ // stay inside releaseChain (it runs in the `publish` job, verifying
140
+ // aggregated same-run evidence before the publish step) — required
141
+ // whenever releaseChain is configured, with its own specific error.
142
+ test("candidate.releaseChain requires evidenceVerificationScript with a specific error", () => {
143
+ const windows = {
144
+ ...VALID_MINIMAL,
145
+ profile: "rust-hybrid",
146
+ gate: { path: "scripts/gate.sh" },
147
+ lanes: ["ci", "release-candidate"],
148
+ matrix: { ...VALID_MINIMAL.matrix, os: ["ubuntu-latest", "windows-2025"] },
149
+ candidate: {
150
+ buildScript: "ci:candidate",
151
+ checkScript: "ci:candidate-check",
152
+ admissionScript: "release:admit",
153
+ stageSummaryScript: "release:stage-summary",
154
+ artifactRoot: "widgets-unsigned",
155
+ os: ["windows-2025"],
156
+ signWindows: true,
157
+ releaseChain: {
158
+ windowsFinalizeScript: "release:finalize:win",
159
+ installedQualificationScript: "release:qualify:installed",
160
+ publishScript: "release:publish:qualified",
161
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
162
+ },
163
+ },
164
+ };
165
+ const result = validateManifest(windows);
166
+ assert.equal(result.valid, false);
167
+ assert.ok(result.errors.includes("candidate.releaseChain.evidenceVerificationScript must be one safe package script name"));
168
+
169
+ const fixed = {
170
+ ...windows,
171
+ candidate: { ...windows.candidate, releaseChain: { ...windows.candidate.releaseChain, evidenceVerificationScript: "release:verify-evidence" } },
172
+ };
173
+ assert.equal(validateManifest(fixed).valid, true, JSON.stringify(validateManifest(fixed).errors));
174
+ });
175
+
71
176
  test("candidate targets permit native multi-architecture matrices independent of normal CI runners", () => {
72
177
  const multiArchitecture = {
73
178
  ...VALID_MINIMAL,
@@ -77,6 +182,8 @@ test("candidate targets permit native multi-architecture matrices independent of
77
182
  candidate: {
78
183
  buildScript: "ci:candidate",
79
184
  checkScript: "ci:candidate-check",
185
+ admissionScript: "release:admit",
186
+ stageSummaryScript: "release:stage-summary",
80
187
  artifactRoot: "widgets-unsigned",
81
188
  os: ["windows-2025", "windows-11-arm", "macos-15", "macos-15-intel"],
82
189
  targets: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
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,6 +15,9 @@
15
15
  "*.json"
16
16
  ],
17
17
  "sideEffects": false,
18
+ "scripts": {
19
+ "test": "node --test *.test.mjs"
20
+ },
18
21
  "publishConfig": {
19
22
  "registry": "https://registry.npmjs.org/",
20
23
  "access": "public"
@@ -24,7 +27,5 @@
24
27
  "url": "git+https://github.com/bogusyogi/claude.git",
25
28
  "directory": "tools/rightkit/packages/git"
26
29
  },
27
- "scripts": {
28
- "test": "node --test *.test.mjs"
29
- }
30
- }
30
+ "packageManager": "pnpm@11.24.0"
31
+ }
package/render.test.mjs CHANGED
@@ -61,9 +61,10 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
61
61
  assert.match(content, /--diff-filter=ACDMRTUXB/, "deleted Rust inputs must trigger Rust setup");
62
62
  assert.match(content, /\*\.rs\|Cargo\.toml[\s\S]*scripts\/gate\.sh/);
63
63
  assert.match(content, /\.rightgit\.json\|\.github\/workflows\/\*\.yml\|\.github\/workflows\/\*\.yaml/);
64
- assert.equal((content.match(/if: steps\.rust-changes\.outputs\.changed == 'true'/g) ?? []).length, 3);
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 \}\}/);
66
- assert.match(content, /RUSTC_WRAPPER: \$\{\{ steps\.rust-changes\.outputs\.changed == 'true' && 'sccache' \|\| '' \}\}/);
66
+ assert.match(content, /RIGHT_GIT_RUST_REQUIRED: \$\{\{ steps\.rust-changes\.outputs\.required \}\}/);
67
+ assert.match(content, /RUSTC_WRAPPER: \$\{\{ steps\.rust-changes\.outputs\.required == 'true' && 'sccache' \|\| '' \}\}/);
67
68
  assert.match(content, /CARGO_INCREMENTAL: "0"/);
68
69
  assert.match(content, /CARGO_PROFILE_DEV_DEBUG: "0"/);
69
70
  assert.match(content, /CARGO_PROFILE_TEST_DEBUG: "0"/);
@@ -100,7 +101,14 @@ test("rust-hybrid release candidate renders product scripts, artifact handoff, a
100
101
  lanes: ["ci", "release-candidate"],
101
102
  profile: "rust-hybrid",
102
103
  gate: { path: "scripts/gate.sh" },
103
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned", os: ["macos-latest", "windows-latest"] },
104
+ candidate: {
105
+ buildScript: "ci:candidate",
106
+ checkScript: "ci:candidate-check",
107
+ admissionScript: "release:admit",
108
+ stageSummaryScript: "release:stage-summary",
109
+ artifactRoot: "widgets-unsigned",
110
+ os: ["macos-latest", "windows-latest"],
111
+ },
104
112
  templateVersion: "1.2.0",
105
113
  };
106
114
  const content = renderTemplate(
@@ -109,6 +117,8 @@ test("rust-hybrid release candidate renders product scripts, artifact handoff, a
109
117
  );
110
118
  assert.match(content, /pnpm run ci:candidate/);
111
119
  assert.match(content, /pnpm run ci:candidate-check/);
120
+ assert.match(content, /pnpm run release:admit/);
121
+ assert.match(content, /pnpm run release:stage-summary/);
112
122
  assert.match(content, /Mozilla-Actions\/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba/);
113
123
  assert.match(content, /shared-key: widgets-rust/);
114
124
  assert.doesNotMatch(content, /bash \.\/scripts\/gate\.sh/);
@@ -121,6 +131,12 @@ test("rust-hybrid release candidate renders product scripts, artifact handoff, a
121
131
  assert.doesNotMatch(content, /rust-changes|RIGHT_GIT_RUST_CHANGED/);
122
132
  assert.doesNotMatch(content, /if: steps\.rust-changes/);
123
133
  assert.match(content, /uses: dtolnay\/rust-toolchain@[a-f0-9]{40}\n with:/);
134
+ // workflow_dispatch tag-last envelope: no windows-sign/macos-sign/release
135
+ // chain jobs render at all when signing is not configured.
136
+ assert.match(content, /^on:\n workflow_dispatch:/m);
137
+ assert.doesNotMatch(content, /^ {2}(?:windows-sign|macos-sign|installed-qualification|publish):\s*$/m);
138
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
139
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
124
140
  });
125
141
 
126
142
  test("rust-hybrid cache contract renders every declared Cargo workspace", () => {
@@ -129,7 +145,7 @@ test("rust-hybrid cache contract renders every declared Cargo workspace", () =>
129
145
  lanes: ["ci"],
130
146
  profile: "rust-hybrid",
131
147
  gate: { path: "scripts/gate.sh" },
132
- rustCache: { workspaces: [".", "apps/studio/src-tauri"] },
148
+ rustCache: { workspaces: [".", "apps/studio/src-tauri"], requiredForGate: true },
133
149
  matrix: { os: ["windows-2025"], node: ["22.23.2"] },
134
150
  };
135
151
  const content = renderTemplate(
@@ -140,7 +156,8 @@ test("rust-hybrid cache contract renders every declared Cargo workspace", () =>
140
156
  assert.match(content, /add-job-id-key: false/);
141
157
  assert.match(content, /cache-workspace-crates: false/);
142
158
  assert.match(content, /cache-on-failure: true/);
143
- assert.match(content, /RUSTC_WRAPPER: \$\{\{ steps\.rust-changes\.outputs\.changed == 'true' && 'sccache' \|\| '' \}\}/);
159
+ assert.match(content, /if \[\[ "true" == "true" \]\]; then[\s\S]*required=true/);
160
+ assert.match(content, /RUSTC_WRAPPER: \$\{\{ steps\.rust-changes\.outputs\.required == 'true' && 'sccache' \|\| '' \}\}/);
144
161
  });
145
162
 
146
163
  test("rust-hybrid release candidate can opt into OIDC Windows signing without long-lived secrets", () => {
@@ -149,7 +166,15 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
149
166
  lanes: ["ci", "release-candidate"],
150
167
  profile: "rust-hybrid",
151
168
  gate: { path: "scripts/gate.sh" },
152
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned", os: ["windows-2025"], signWindows: true },
169
+ candidate: {
170
+ buildScript: "ci:candidate",
171
+ checkScript: "ci:candidate-check",
172
+ admissionScript: "release:admit",
173
+ stageSummaryScript: "release:stage-summary",
174
+ artifactRoot: "widgets-unsigned",
175
+ os: ["windows-2025"],
176
+ signWindows: true,
177
+ },
153
178
  matrix: { os: ["ubuntu-24.04", "windows-2025"], node: ["22.23.2"] },
154
179
  templateVersion: "1.3.0",
155
180
  };
@@ -158,16 +183,26 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
158
183
  buildRenderContext(manifest, "/repos/widgets"),
159
184
  );
160
185
  assert.match(content, /environment: release/);
161
- assert.match(content, /windows-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.signed_qualification == true\) \}\}/);
186
+ // Tag-last contract: signing is gated on the admitted, same-run
187
+ // signed_qualification OUTPUT of the `admission` job — never on a ref/tag
188
+ // and never on the raw workflow_dispatch input directly.
189
+ assert.match(content, /windows-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
190
+ assert.doesNotMatch(content, /startsWith\(github\.ref, 'refs\/tags\/v'\)/);
162
191
  assert.match(content, /id-token: write/);
163
192
  assert.match(content, /azure\/login@[a-f0-9]{40}/);
164
193
  assert.match(content, /vars\.AZURE_CLIENT_ID/);
165
194
  assert.match(content, /right-release build --platform win --skip-checks/);
166
195
  assert.match(content, /Artifact Signing client hash mismatch/);
167
- assert.match(content, /RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/);
196
+ // Every downstream job (admission, candidate, and windows-sign here) checks
197
+ // out the exact admitted revision by SHA, never by resolving HEAD after a
198
+ // ref-triggered checkout.
199
+ assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/);
200
+ assert.doesNotMatch(content, /git rev-parse HEAD/);
168
201
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
169
202
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
170
203
  assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
204
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
205
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
171
206
  });
172
207
 
173
208
  test("rust-hybrid release candidate can opt into protected macOS signing & notarization", () => {
@@ -176,7 +211,15 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
176
211
  lanes: ["ci", "release-candidate"],
177
212
  profile: "rust-hybrid",
178
213
  gate: { path: "scripts/gate.sh" },
179
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned", os: ["macos-15"], signMacos: true },
214
+ candidate: {
215
+ buildScript: "ci:candidate",
216
+ checkScript: "ci:candidate-check",
217
+ admissionScript: "release:admit",
218
+ stageSummaryScript: "release:stage-summary",
219
+ artifactRoot: "widgets-unsigned",
220
+ os: ["macos-15"],
221
+ signMacos: true,
222
+ },
180
223
  matrix: { os: ["ubuntu-24.04", "macos-15"], node: ["22.23.2"] },
181
224
  templateVersion: "1.3.0",
182
225
  };
@@ -185,17 +228,21 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
185
228
  buildRenderContext(manifest, "/repos/widgets"),
186
229
  );
187
230
  assert.match(content, /macos-sign:/);
188
- assert.match(content, /macos-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.signed_qualification == true\) \}\}/);
231
+ assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
232
+ assert.doesNotMatch(content, /startsWith\(github\.ref, 'refs\/tags\/v'\)/);
189
233
  assert.match(content, /environment: release/);
190
234
  assert.match(content, /secrets\.APPLE_CERTIFICATE_BASE64/);
191
235
  assert.match(content, /temporary keychain/i);
192
236
  assert.match(content, /right-release build --platform mac --skip-checks/);
193
237
  assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
194
- assert.match(content, /RIGHT_GIT_SOURCE_REVISION=%s\\n.*git rev-parse HEAD/);
238
+ assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/);
239
+ assert.doesNotMatch(content, /git rev-parse HEAD/);
195
240
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
196
241
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
197
242
  assert.match(content, /security delete-keychain/);
198
243
  assert.doesNotMatch(content, /pull_request_target/);
244
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
245
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
199
246
  });
200
247
 
201
248
  test("rust-hybrid multi-architecture candidates bind every native target to exact artifacts and signing", () => {
@@ -208,6 +255,8 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
208
255
  candidate: {
209
256
  buildScript: "ci:candidate",
210
257
  checkScript: "ci:candidate-check",
258
+ admissionScript: "release:admit",
259
+ stageSummaryScript: "release:stage-summary",
211
260
  artifactRoot: "widgets-unsigned",
212
261
  os: ["windows-2025", "windows-11-arm", "macos-15", "macos-15-intel"],
213
262
  targets: [
@@ -233,10 +282,15 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
233
282
  assert.doesNotMatch(content, /bash \.\/scripts\/gate\.sh/);
234
283
  assert.match(content, /windows-sign:[\s\S]*matrix:[\s\S]*windows-11-arm/);
235
284
  assert.match(content, /macos-sign:[\s\S]*matrix:[\s\S]*macos-15-intel/);
236
- assert.match(content, /name: widgets-unsigned-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
237
- assert.match(content, /name: widgets-signed-windows-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
238
- assert.match(content, /name: widgets-signed-macos-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
239
- assert.equal((content.match(/RIGHT_GIT_RELEASE_ARCHITECTURE: \$\{\{ matrix\.architecture \}\}/g) ?? []).length, 4);
285
+ // Every artifact name is run- and identity-bound: <prefix>-<run_id>-<run_attempt>-<kind>-...-<version>-<source_revision>.
286
+ assert.match(content, /name: widgets-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-candidate-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.architecture \}\}-\$\{\{ needs\.admission\.outputs\.version \}\}-\$\{\{ needs\.admission\.outputs\.source_revision \}\}/);
287
+ assert.match(content, /name: widgets-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-signed-windows-\$\{\{ matrix\.architecture \}\}-\$\{\{ needs\.admission\.outputs\.version \}\}-\$\{\{ needs\.admission\.outputs\.source_revision \}\}/);
288
+ assert.match(content, /name: widgets-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-signed-macos-\$\{\{ matrix\.architecture \}\}-\$\{\{ needs\.admission\.outputs\.version \}\}-\$\{\{ needs\.admission\.outputs\.source_revision \}\}/);
289
+ // Exact-count regression guard across the candidate job's build/check/
290
+ // stage-summary steps and the windows-sign/macos-sign stage-summary steps.
291
+ assert.equal((content.match(/RIGHT_GIT_RELEASE_ARCHITECTURE: \$\{\{ matrix\.architecture \}\}/g) ?? []).length, 10);
292
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
293
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
240
294
  });
241
295
 
242
296
  test("rust-hybrid release chain preserves signed candidate, installed qualification, then publish", () => {
@@ -248,11 +302,14 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
248
302
  candidate: {
249
303
  buildScript: "ci:candidate",
250
304
  checkScript: "ci:candidate-check",
305
+ admissionScript: "release:admit",
306
+ stageSummaryScript: "release:stage-summary",
251
307
  artifactRoot: "widgets-unsigned",
252
308
  os: ["windows-2025", "macos-15"],
253
309
  signWindows: true,
254
310
  signMacos: true,
255
311
  releaseChain: {
312
+ evidenceVerificationScript: "release:verify-evidence",
256
313
  windowsFinalizeScript: "release:finalize:win",
257
314
  macosFinalizeScript: "release:finalize:mac",
258
315
  installedQualificationScript: "release:qualify:installed",
@@ -270,26 +327,202 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
270
327
  );
271
328
  assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
272
329
  assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
273
- assert.match(content, /signed_qualification:\n description: Run protected signing & installed qualification without publishing/);
274
- assert.match(content, /windows-sign:[\s\S]*inputs\.signed_qualification == true/);
275
- assert.match(content, /macos-sign:[\s\S]*inputs\.signed_qualification == true/);
276
- assert.match(content, /installed-qualification:\n needs: \[candidate, windows-sign\]/);
277
- assert.match(content, /installed-qualification:[\s\S]*inputs\.signed_qualification == true/);
330
+ // Tag-last contract: every gate reads the ADMITTED, same-run outputs of
331
+ // the `admission` job — never the raw workflow_dispatch input directly,
332
+ // and never a ref/tag.
333
+ assert.match(content, /signed_qualification:\n description: Run protected signing, installed qualification, & evidence verification/);
334
+ assert.doesNotMatch(content, /inputs\.signed_qualification == true/);
335
+ assert.doesNotMatch(content, /startsWith\(github\.ref, 'refs\/tags\/v'\)/);
336
+ assert.match(content, /windows-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
337
+ assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
338
+ assert.match(content, /installed-qualification:\n needs: \[admission, candidate, windows-sign\]/);
339
+ assert.match(content, /installed-qualification:[\s\S]*?if: \$\{\{ always\(\) && needs\.admission\.outputs\.signed_qualification == 'true' && needs\.candidate\.result == 'success' \}\}/);
278
340
  assert.match(content, /installed_qualification_failed/);
279
341
  assert.match(content, /shell: pwsh\n run: \|\n throw 'installed_qualification_failed \{ reason: prerequisites_absent, detail: "same-run Windows signing\/finalization did not succeed" \}'/);
280
342
  assert.doesNotMatch(content, /^ run: throw 'installed_qualification_failed .*detail: /m);
281
343
  assert.match(content, /pnpm run release:qualify:installed/);
282
344
  assert.match(content, /installed-qualification:[\s\S]*GH_TOKEN: \$\{\{ github\.token \}\}/);
283
- assert.match(content, /publish:\n needs: \[installed-qualification, macos-sign\]/);
284
- assert.match(content, /publish:[\s\S]*startsWith\(github\.ref, 'refs\/tags\/v'\)/);
285
- assert.doesNotMatch(content.slice(content.indexOf(" publish:")), /inputs\.signed_qualification/);
286
- assert.match(content, /needs\.macos-sign\.result == 'success'/);
345
+ assert.match(content, /publish:\n needs: \[admission, installed-qualification, macos-sign\]/);
346
+ assert.match(content, /publish:[\s\S]*?if: \$\{\{ needs\.admission\.outputs\.publish == 'true' && needs\.installed-qualification\.result == 'success' && needs\.macos-sign\.result == 'success' \}\}/);
347
+ const publishJobIndex = content.search(/^ {2}publish:\n {4}needs:/m);
348
+ assert.ok(publishJobIndex >= 0, "publish job must be present");
349
+ assert.doesNotMatch(content.slice(publishJobIndex), /inputs\.signed_qualification|inputs\.publish/);
287
350
  assert.match(content, /contents: write/);
351
+ // Publish is the sole creator of the tag/release: it must not require a
352
+ // pre-existing tag anywhere in its gate.
353
+ assert.doesNotMatch(content.slice(publishJobIndex), /refs\/tags/);
354
+ assert.match(content, /pnpm run release:verify-evidence/);
355
+ assert.match(content, /Verify aggregated release evidence[\s\S]*?pnpm run release:verify-evidence/);
288
356
  assert.match(content, /pnpm run release:publish:qualified/);
289
357
  assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
290
- assert.equal((content.match(/RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/g) ?? []).length, 3);
358
+ // The old ref-triggered "resolve HEAD after checkout" trick is gone
359
+ // entirely for the releaseChain path — every job instead checks out the
360
+ // exact admitted SHA from the admission job's own output.
361
+ assert.doesNotMatch(content, /git rev-parse HEAD/);
291
362
  assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
363
+ // windows-sign, macos-sign, installed-qualification, publish all check out
364
+ // the admitted revision directly followed by persist-credentials: false;
365
+ // candidate does too but with a fetch-depth: 0 line between them (matched
366
+ // separately below), and admission checks out ${{ inputs.source_revision }}
367
+ // (it IS the admission job — there is no needs.admission yet).
368
+ assert.equal((content.match(/ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/g) ?? []).length, 4, "windows-sign, macos-sign, installed-qualification, publish should all pin the admitted revision");
369
+ assert.match(content, /ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/, "candidate must also pin the admitted revision");
370
+ 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");
292
371
  assert.doesNotMatch(content.slice(0, content.indexOf(" windows-sign:")), /secrets\.|id-token: write|contents: write/);
372
+ // Downstream download-artifact names must match exactly what windows-sign/
373
+ // macos-sign uploaded (see the multi-architecture test) — this is the
374
+ // regression guard for the artifact-name mismatch bug.
375
+ const windowsSignedArtifact = "widgets-${{ github.run_id }}-${{ github.run_attempt }}-signed-windows-x86_64-${{ needs.admission.outputs.version }}-${{ needs.admission.outputs.source_revision }}";
376
+ const macosSignedArtifact = "widgets-${{ github.run_id }}-${{ github.run_attempt }}-signed-macos-arm64-${{ needs.admission.outputs.version }}-${{ needs.admission.outputs.source_revision }}";
377
+ assert.equal((content.match(new RegExp(windowsSignedArtifact.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length, 2, "windows-sign upload + installed-qualification/publish download must share one artifact name");
378
+ assert.ok(content.includes(macosSignedArtifact), "publish must download the exact macOS artifact macos-sign uploaded");
379
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
380
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
381
+ });
382
+
383
+ // Part E regression guard: every `run: <pm> run <script>` line in a
384
+ // rendered full release-chain workflow must name a script the manifest
385
+ // actually declares. This is the general form of the `pnpm run undefined`
386
+ // class of bug — it would also catch a typo'd or stale script reference
387
+ // even when it resolves to a real (but wrong) string, which the fail-closed
388
+ // undefined/null check in buildRenderContext cannot.
389
+ test("every 'run <script>' in a full release-chain workflow names a script the manifest declares", () => {
390
+ const manifest = {
391
+ ...FULL_MANIFEST,
392
+ lanes: ["ci", "release-candidate"],
393
+ profile: "rust-hybrid",
394
+ gate: { path: "scripts/gate.sh" },
395
+ candidate: {
396
+ buildScript: "ci:candidate",
397
+ checkScript: "ci:candidate-check",
398
+ admissionScript: "release:admit",
399
+ stageSummaryScript: "release:stage-summary",
400
+ artifactRoot: "widgets-unsigned",
401
+ os: ["windows-2025", "macos-15"],
402
+ signWindows: true,
403
+ signMacos: true,
404
+ releaseChain: {
405
+ evidenceVerificationScript: "release:verify-evidence",
406
+ windowsFinalizeScript: "release:finalize:win",
407
+ macosFinalizeScript: "release:finalize:mac",
408
+ installedQualificationScript: "release:qualify:installed",
409
+ publishScript: "release:publish:qualified",
410
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
411
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
412
+ },
413
+ },
414
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
415
+ templateVersion: "1.4.0",
416
+ };
417
+ const content = renderTemplate(
418
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
419
+ buildRenderContext(manifest, "/repos/widgets"),
420
+ );
421
+ const declaredScripts = new Set([
422
+ manifest.candidate.buildScript,
423
+ manifest.candidate.checkScript,
424
+ manifest.candidate.admissionScript,
425
+ manifest.candidate.stageSummaryScript,
426
+ manifest.candidate.releaseChain.evidenceVerificationScript,
427
+ manifest.candidate.releaseChain.windowsFinalizeScript,
428
+ manifest.candidate.releaseChain.macosFinalizeScript,
429
+ manifest.candidate.releaseChain.installedQualificationScript,
430
+ manifest.candidate.releaseChain.publishScript,
431
+ ]);
432
+ const runLines = content.match(/run: (?:pnpm|npm|yarn) run \S+/g) ?? [];
433
+ assert.ok(runLines.length > 0, "expected at least one package-script run line");
434
+ for (const line of runLines) {
435
+ const script = line.split(" run ").pop();
436
+ assert.ok(declaredScripts.has(script), `rendered step runs undeclared script: ${line}`);
437
+ }
438
+ });
439
+
440
+ // Downstream-consumer gap fix: the evidence verifier can only prove a stage
441
+ // ran and succeeded if that stage produces a stage summary AND the publish
442
+ // job actually downloads it. This test proves all four authoritative
443
+ // stages (candidate, windows-sign, macos-sign, installed-qualification)
444
+ // produce a summary, that installed-qualification wraps itself in
445
+ // init/finalize like every other stage, that publish downloads the
446
+ // complete same-run set into distinct subdirectories (no collisions
447
+ // between candidate's two platform legs), and that evidence is bound to
448
+ // this exact run (not merely version+source_revision) via RIGHT_GIT_RUN_ID/
449
+ // RIGHT_GIT_RUN_ATTEMPT on both the verification and publish steps.
450
+ test("release chain: installed-qualification has a stage summary and publish downloads the complete same-run evidence set", () => {
451
+ const manifest = {
452
+ ...FULL_MANIFEST,
453
+ lanes: ["ci", "release-candidate"],
454
+ profile: "rust-hybrid",
455
+ gate: { path: "scripts/gate.sh" },
456
+ candidate: {
457
+ buildScript: "ci:candidate",
458
+ checkScript: "ci:candidate-check",
459
+ admissionScript: "release:admit",
460
+ stageSummaryScript: "release:stage-summary",
461
+ artifactRoot: "widgets-unsigned",
462
+ os: ["windows-2025", "macos-15"],
463
+ signWindows: true,
464
+ signMacos: true,
465
+ releaseChain: {
466
+ evidenceVerificationScript: "release:verify-evidence",
467
+ windowsFinalizeScript: "release:finalize:win",
468
+ macosFinalizeScript: "release:finalize:mac",
469
+ installedQualificationScript: "release:qualify:installed",
470
+ publishScript: "release:publish:qualified",
471
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
472
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
473
+ },
474
+ },
475
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
476
+ templateVersion: "1.4.0",
477
+ };
478
+ const content = renderTemplate(
479
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
480
+ buildRenderContext(manifest, "/repos/widgets"),
481
+ );
482
+ const installedQualificationJob = content.slice(
483
+ content.search(/^ {2}installed-qualification:\n {4}needs:/m),
484
+ content.search(/^ {2}publish:\n {4}needs:/m),
485
+ );
486
+
487
+ // Point 1: installed-qualification wraps itself in stage-summary
488
+ // init/finalize like candidate/windows-sign/macos-sign, with the correct
489
+ // authoritative stage identifier, and finalize runs unconditionally.
490
+ assert.match(installedQualificationJob, /name: Initialize installed-qualification stage summary\n\s+run: pnpm run release:stage-summary\n\s+env:\n\s+RIGHT_GIT_STAGE_ACTION: init/);
491
+ assert.match(installedQualificationJob, /RIGHT_GIT_STAGE: installed-qualification/);
492
+ assert.match(installedQualificationJob, /name: Finalize installed-qualification stage summary\n\s+if: \$\{\{ always\(\) \}\}\n\s+run: pnpm run release:stage-summary\n\s+env:\n\s+RIGHT_GIT_STAGE_ACTION: finalize/);
493
+ assert.equal((installedQualificationJob.match(/RIGHT_GIT_STAGE: installed-qualification/g) ?? []).length, 2, "both init and finalize must carry the installed-qualification stage id");
494
+ assert.match(installedQualificationJob, /name: Upload installed-qualification stage summary\n\s+if: \$\{\{ always\(\) \}\}/);
495
+
496
+ const publishJob = content.slice(content.search(/^ {2}publish:\n {4}needs:/m));
497
+
498
+ // Point 2: publish downloads the complete same-run evidence set — both
499
+ // candidate legs (windows + macos) in distinct subdirectories, plus
500
+ // windows-sign, macos-sign, and installed-qualification summaries.
501
+ assert.match(publishJob, /Download same-run candidate \(Windows\) stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/candidate-windows/);
502
+ assert.match(publishJob, /Download same-run candidate \(macOS\) stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/candidate-macos/);
503
+ assert.match(publishJob, /Download same-run Windows signing stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/windows-sign/);
504
+ assert.match(publishJob, /Download same-run macOS signing stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/macos-sign/);
505
+ assert.match(publishJob, /Download same-run installed-qualification stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/installed-qualification/);
506
+ // Distinct subdirectories: no two downloads share a path.
507
+ const downloadPaths = [...publishJob.matchAll(/path: (\$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/\S+)/g)].map((m) => m[1]);
508
+ assert.equal(downloadPaths.length, 5);
509
+ assert.equal(new Set(downloadPaths).size, 5, "every stage-summary download must use a distinct subdirectory");
510
+
511
+ // Point 3: both the evidence-verification step and the publish step carry
512
+ // the full run-identity contract (RIGHT_GIT_STAGE_SUMMARY_ROOT, run id,
513
+ // run attempt) alongside version/source_revision — evidence is bound to
514
+ // THIS run, not merely reusable across runs of the same version+SHA.
515
+ const verifyStep = publishJob.slice(publishJob.indexOf("Verify aggregated release evidence"), publishJob.indexOf("Publish qualified signed release"));
516
+ assert.match(verifyStep, /RIGHT_GIT_STAGE_SUMMARY_ROOT: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries/);
517
+ assert.match(verifyStep, /RIGHT_GIT_RUN_ID: \$\{\{ github\.run_id \}\}/);
518
+ assert.match(verifyStep, /RIGHT_GIT_RUN_ATTEMPT: \$\{\{ github\.run_attempt \}\}/);
519
+ const publishStep = publishJob.slice(publishJob.indexOf("Publish qualified signed release"));
520
+ assert.match(publishStep, /RIGHT_GIT_STAGE_SUMMARY_ROOT: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries/);
521
+ assert.match(publishStep, /RIGHT_GIT_RUN_ID: \$\{\{ github\.run_id \}\}/);
522
+ assert.match(publishStep, /RIGHT_GIT_RUN_ATTEMPT: \$\{\{ github\.run_attempt \}\}/);
523
+
524
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
525
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
293
526
  });
294
527
 
295
528
  test("render determinism: same manifest renders byte-identical output every time", () => {
package/sync.test.mjs CHANGED
@@ -67,11 +67,14 @@ test("sync rejects a Membrane-like release chain without root @rightkit/release"
67
67
  candidate: {
68
68
  buildScript: "release:candidate:build",
69
69
  checkScript: "release:candidate:check",
70
+ admissionScript: "release:admit",
71
+ stageSummaryScript: "release:stage-summary",
70
72
  artifactRoot: "membrane-release-candidate",
71
73
  os: ["windows-2025", "macos-15"],
72
74
  signWindows: true,
73
75
  signMacos: true,
74
76
  releaseChain: {
77
+ evidenceVerificationScript: "release:verify-evidence",
75
78
  windowsFinalizeScript: "release:finalize:windows",
76
79
  macosFinalizeScript: "release:finalize:macos",
77
80
  installedQualificationScript: "release:qualify:installed",
@@ -54,17 +54,22 @@ jobs:
54
54
  fi
55
55
  fi
56
56
  printf 'changed=%s\n' "$changed" >> "$GITHUB_OUTPUT"
57
+ required="$changed"
58
+ if [[ "${RUST_REQUIRED_FOR_GATE}" == "true" ]]; then
59
+ required=true
60
+ fi
61
+ printf 'required=%s\n' "$required" >> "$GITHUB_OUTPUT"
57
62
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
58
63
  with:
59
64
  node-version: ${{ matrix.node }}
60
65
  package-manager-cache: false
61
66
  ${PACKAGE_MANAGER_SETUP}
62
67
  - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
63
- if: steps.rust-changes.outputs.changed == 'true'
68
+ if: steps.rust-changes.outputs.required == 'true'
64
69
  with:
65
70
  components: rustfmt, clippy
66
71
  - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
67
- if: steps.rust-changes.outputs.changed == 'true'
72
+ if: steps.rust-changes.outputs.required == 'true'
68
73
  with:
69
74
  workspaces: |
70
75
  ${RUST_CACHE_WORKSPACES}
@@ -74,14 +79,15 @@ ${RUST_CACHE_WORKSPACES}
74
79
  cache-on-failure: true
75
80
  save-if: ${{ github.event_name != 'pull_request' }}
76
81
  - uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
77
- if: steps.rust-changes.outputs.changed == 'true'
82
+ if: steps.rust-changes.outputs.required == 'true'
78
83
  with:
79
84
  version: "v0.16.0"
80
85
  - run: bash ./${GATE_PATH}
81
86
  env:
82
87
  RIGHT_GIT_RUST_CHANGED: ${{ steps.rust-changes.outputs.changed }}
88
+ RIGHT_GIT_RUST_REQUIRED: ${{ steps.rust-changes.outputs.required }}
83
89
  SCCACHE_GHA_ENABLED: "true"
84
- RUSTC_WRAPPER: ${{ steps.rust-changes.outputs.changed == 'true' && 'sccache' || '' }}
90
+ RUSTC_WRAPPER: ${{ steps.rust-changes.outputs.required == 'true' && 'sccache' || '' }}
85
91
  CARGO_INCREMENTAL: "0"
86
92
  CARGO_PROFILE_DEV_DEBUG: "0"
87
93
  CARGO_PROFILE_TEST_DEBUG: "0"