mandrel-platform 0.17.2 → 0.18.0
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 +220 -14
- package/config/commitlint.base.mjs +36 -0
- package/config/repo-settings.schema.json +78 -0
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* overrides preserved),
|
|
13
13
|
* 4. idempotency + `--dry-run` non-mutation.
|
|
14
14
|
*
|
|
15
|
+
* Also exercises the two GitHub-side drift-check modes, which short-circuit
|
|
16
|
+
* the local-checkout sync above and operate over the GitHub API instead:
|
|
17
|
+
* 5. `--check-settings` / `--apply-settings` (Story #171, repo settings).
|
|
18
|
+
* 6. `--check-ruleset` (Story #178, branch rulesets — report-only, no
|
|
19
|
+
* `--apply-ruleset`).
|
|
20
|
+
*
|
|
15
21
|
* Run: node scripts/platform-sync.test.mjs (or `node --test scripts/`)
|
|
16
22
|
*/
|
|
17
23
|
|
|
@@ -76,6 +82,7 @@ test("--dry-run does not mutate any file", () => {
|
|
|
76
82
|
const after = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
|
|
77
83
|
assert.equal(after, before, "ci.yml must be untouched in dry-run");
|
|
78
84
|
assert.ok(!existsSync(join(consumer, "docs", "runbooks", "observability.md")));
|
|
85
|
+
assert.ok(!existsSync(join(consumer, ".github", "workflows", "deploy-staging.yml")));
|
|
79
86
|
});
|
|
80
87
|
|
|
81
88
|
test("apply pins first-party SHAs, leaves external actions untouched", () => {
|
|
@@ -87,6 +94,38 @@ test("apply pins first-party SHAs, leaves external actions untouched", () => {
|
|
|
87
94
|
assert.ok(ci.includes(`actions/checkout@${"2".repeat(40)} # external`), "external action untouched");
|
|
88
95
|
});
|
|
89
96
|
|
|
97
|
+
test("ciNaming: flags a non-canonical job id (seeded fixture uses job id 'q', not 'ci')", () => {
|
|
98
|
+
const out = JSON.parse(run([]));
|
|
99
|
+
assert.equal(out.ciNaming.status, "non-canonical");
|
|
100
|
+
assert.match(out.ciNaming.message, /no "ci" job id/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("ciNaming: reports 'no-canonical-file' when no ci.yml exists", () => {
|
|
104
|
+
rmSync(join(consumer, ".github", "workflows", "ci.yml"));
|
|
105
|
+
const out = JSON.parse(run([]));
|
|
106
|
+
assert.equal(out.ciNaming.status, "no-canonical-file");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("ciNaming: reports 'canonical' when ci.yml matches the triplet (display name CI + job id ci)", () => {
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(consumer, ".github", "workflows", "ci.yml"),
|
|
112
|
+
["name: CI", "jobs:", " ci:", " steps:", " - run: echo hi", ""].join("\n")
|
|
113
|
+
);
|
|
114
|
+
const out = JSON.parse(run([]));
|
|
115
|
+
assert.equal(out.ciNaming.status, "canonical");
|
|
116
|
+
assert.equal(out.ciNaming.message, null);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("ciNaming never mutates ci.yml — it is advisory only", () => {
|
|
120
|
+
const before = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
|
|
121
|
+
run([]);
|
|
122
|
+
const after = readFileSync(join(consumer, ".github", "workflows", "ci.yml"), "utf8");
|
|
123
|
+
// Only the pin rewrite (already covered above) may change bytes; re-read the
|
|
124
|
+
// *naming* fields specifically — name:/job id text must be byte-identical.
|
|
125
|
+
assert.equal(before.match(/^name:.*$/m)?.[0], after.match(/^name:.*$/m)?.[0]);
|
|
126
|
+
assert.equal(before.includes(" q:"), after.includes(" q:"));
|
|
127
|
+
});
|
|
128
|
+
|
|
90
129
|
test("apply materializes runbook reference stubs (link, don't copy)", () => {
|
|
91
130
|
run([]);
|
|
92
131
|
const stub = join(consumer, "docs", "runbooks", "deploy-promotion.md");
|
|
@@ -138,3 +177,441 @@ test("an existing reference stub is skipped idempotently", () => {
|
|
|
138
177
|
assert.ok(out.runbooks.skipped.length >= 8, "already-present stubs are skipped, not re-created");
|
|
139
178
|
assert.equal(out.runbooks.created.length, 0);
|
|
140
179
|
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// --check-settings / --apply-settings (Story #171)
|
|
183
|
+
//
|
|
184
|
+
// platform-sync.mjs shells out to the real `gh` CLI for the GitHub-side
|
|
185
|
+
// settings mode (no dependency-injection seam like check-pin-drift.mjs), so
|
|
186
|
+
// these tests stub `gh` as a fake executable on PATH and drive the CLI
|
|
187
|
+
// end-to-end. The pure diff/classification logic itself is unit-tested in
|
|
188
|
+
// check-repo-settings.test.mjs; this suite exercises platform-sync's own
|
|
189
|
+
// argument wiring, PATCH-call shape, and non-blocking exit-code contract.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
let settingsDir;
|
|
193
|
+
let fakeGhDir;
|
|
194
|
+
let fakeGhLogPath;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Write a fake `gh` shell script onto a scratch PATH dir that:
|
|
198
|
+
* - responds to `gh api repos/<repo>` and `gh api repos/<repo>/actions/permissions/workflow`
|
|
199
|
+
* with the JSON bodies given in `responses` (keyed by the API path),
|
|
200
|
+
* - logs every invocation's args (one JSON line per call) to `fakeGhLogPath`
|
|
201
|
+
* so PATCH calls can be asserted on,
|
|
202
|
+
* - exits 0 for any recognized `gh api ...` / `gh api -X PATCH ...` call.
|
|
203
|
+
*/
|
|
204
|
+
function writeFakeGh(responses) {
|
|
205
|
+
const script = `#!/usr/bin/env node
|
|
206
|
+
const fs = require("node:fs");
|
|
207
|
+
const args = process.argv.slice(2);
|
|
208
|
+
fs.appendFileSync(${JSON.stringify(fakeGhLogPath)}, JSON.stringify(args) + "\\n");
|
|
209
|
+
const responses = ${JSON.stringify(responses)};
|
|
210
|
+
if (args[0] === "api") {
|
|
211
|
+
const isPatch = args[1] === "-X" && args[2] === "PATCH";
|
|
212
|
+
const path = isPatch ? args[3] : args[1];
|
|
213
|
+
if (isPatch) {
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
if (Object.prototype.hasOwnProperty.call(responses, path)) {
|
|
217
|
+
process.stdout.write(JSON.stringify(responses[path]));
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
process.stderr.write("no stub for " + path + "\\n");
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
process.exit(1);
|
|
224
|
+
`;
|
|
225
|
+
const ghPath = join(fakeGhDir, "gh");
|
|
226
|
+
writeFileSync(ghPath, script, { mode: 0o755 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function runSettings(extraArgs) {
|
|
230
|
+
return execFileSync("node", [CLI, ...extraArgs], {
|
|
231
|
+
encoding: "utf8",
|
|
232
|
+
env: { ...process.env, PATH: `${fakeGhDir}:${process.env.PATH}` },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const BASELINE_SETTINGS = {
|
|
237
|
+
allowSquashMerge: true,
|
|
238
|
+
allowMergeCommit: false,
|
|
239
|
+
allowRebaseMerge: false,
|
|
240
|
+
squashMergeCommitTitle: "PR_TITLE",
|
|
241
|
+
squashMergeCommitMessage: "PR_BODY",
|
|
242
|
+
deleteBranchOnMerge: true,
|
|
243
|
+
allowAutoMerge: true,
|
|
244
|
+
actionsDefaultWorkflowPermissions: "read",
|
|
245
|
+
actionsCanApprovePullRequestReviews: false,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
settingsDir = mkdtempSync(join(tmpdir(), "platform-sync-settings-test-"));
|
|
250
|
+
fakeGhDir = mkdtempSync(join(tmpdir(), "platform-sync-fakegh-"));
|
|
251
|
+
fakeGhLogPath = join(settingsDir, "gh-calls.log");
|
|
252
|
+
writeFileSync(fakeGhLogPath, "");
|
|
253
|
+
writeFileSync(join(settingsDir, "baseline.json"), JSON.stringify(BASELINE_SETTINGS));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
rmSync(settingsDir, { recursive: true, force: true });
|
|
258
|
+
rmSync(fakeGhDir, { recursive: true, force: true });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("--check-settings reports no drift when live settings match the baseline", () => {
|
|
262
|
+
writeFakeGh({
|
|
263
|
+
"repos/dsj1984/swarm-os": {
|
|
264
|
+
allow_squash_merge: true,
|
|
265
|
+
allow_merge_commit: false,
|
|
266
|
+
allow_rebase_merge: false,
|
|
267
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
268
|
+
squash_merge_commit_message: "PR_BODY",
|
|
269
|
+
delete_branch_on_merge: true,
|
|
270
|
+
allow_auto_merge: true,
|
|
271
|
+
},
|
|
272
|
+
"repos/dsj1984/swarm-os/actions/permissions/workflow": {
|
|
273
|
+
default_workflow_permissions: "read",
|
|
274
|
+
can_approve_pull_request_reviews: false,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
const out = JSON.parse(
|
|
278
|
+
runSettings([
|
|
279
|
+
"--check-settings",
|
|
280
|
+
"--consumer-repo",
|
|
281
|
+
"dsj1984/swarm-os",
|
|
282
|
+
"--baseline",
|
|
283
|
+
join(settingsDir, "baseline.json"),
|
|
284
|
+
"--json",
|
|
285
|
+
])
|
|
286
|
+
);
|
|
287
|
+
assert.equal(out.mode, "check-settings");
|
|
288
|
+
assert.equal(out.drift, false);
|
|
289
|
+
assert.deepEqual(out.mismatches, []);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("--check-settings reports drift (domio-shaped: write token perms) without mutating anything", () => {
|
|
293
|
+
writeFakeGh({
|
|
294
|
+
"repos/dsj1984/domio": {
|
|
295
|
+
allow_squash_merge: true,
|
|
296
|
+
allow_merge_commit: false,
|
|
297
|
+
allow_rebase_merge: false,
|
|
298
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
299
|
+
squash_merge_commit_message: "PR_BODY",
|
|
300
|
+
delete_branch_on_merge: true,
|
|
301
|
+
allow_auto_merge: true,
|
|
302
|
+
},
|
|
303
|
+
"repos/dsj1984/domio/actions/permissions/workflow": {
|
|
304
|
+
default_workflow_permissions: "write",
|
|
305
|
+
can_approve_pull_request_reviews: false,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
const out = JSON.parse(
|
|
309
|
+
runSettings([
|
|
310
|
+
"--check-settings",
|
|
311
|
+
"--consumer-repo",
|
|
312
|
+
"dsj1984/domio",
|
|
313
|
+
"--baseline",
|
|
314
|
+
join(settingsDir, "baseline.json"),
|
|
315
|
+
"--json",
|
|
316
|
+
])
|
|
317
|
+
);
|
|
318
|
+
assert.equal(out.drift, true);
|
|
319
|
+
assert.deepEqual(out.mismatches, [
|
|
320
|
+
{ field: "actionsDefaultWorkflowPermissions", expected: "read", actual: "write" },
|
|
321
|
+
]);
|
|
322
|
+
assert.equal(out.applied, false, "--check-settings never applies");
|
|
323
|
+
const calls = readFileSync(fakeGhLogPath, "utf8").trim().split("\n").filter(Boolean);
|
|
324
|
+
assert.ok(
|
|
325
|
+
calls.every((line) => !JSON.parse(line).includes("PATCH")),
|
|
326
|
+
"no PATCH call issued by --check-settings"
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("--check-settings never fails the exit code on drift (non-blocking, standing decision #10)", () => {
|
|
331
|
+
writeFakeGh({
|
|
332
|
+
"repos/dsj1984/athportal": {
|
|
333
|
+
allow_squash_merge: true,
|
|
334
|
+
allow_merge_commit: true,
|
|
335
|
+
allow_rebase_merge: true,
|
|
336
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
337
|
+
squash_merge_commit_message: "COMMIT_MESSAGES",
|
|
338
|
+
delete_branch_on_merge: true,
|
|
339
|
+
allow_auto_merge: true,
|
|
340
|
+
},
|
|
341
|
+
"repos/dsj1984/athportal/actions/permissions/workflow": {
|
|
342
|
+
default_workflow_permissions: "read",
|
|
343
|
+
can_approve_pull_request_reviews: true,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
// execFileSync throws on non-zero exit; a clean return proves exit 0.
|
|
347
|
+
assert.doesNotThrow(() => {
|
|
348
|
+
runSettings([
|
|
349
|
+
"--check-settings",
|
|
350
|
+
"--consumer-repo",
|
|
351
|
+
"dsj1984/athportal",
|
|
352
|
+
"--baseline",
|
|
353
|
+
join(settingsDir, "baseline.json"),
|
|
354
|
+
"--json",
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("--apply-settings PATCHes only the drifted fields, across both endpoints", () => {
|
|
360
|
+
writeFakeGh({
|
|
361
|
+
"repos/dsj1984/athportal": {
|
|
362
|
+
allow_squash_merge: true,
|
|
363
|
+
allow_merge_commit: true, // drift
|
|
364
|
+
allow_rebase_merge: true, // drift
|
|
365
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
366
|
+
squash_merge_commit_message: "PR_BODY",
|
|
367
|
+
delete_branch_on_merge: true,
|
|
368
|
+
allow_auto_merge: true,
|
|
369
|
+
},
|
|
370
|
+
"repos/dsj1984/athportal/actions/permissions/workflow": {
|
|
371
|
+
default_workflow_permissions: "read",
|
|
372
|
+
can_approve_pull_request_reviews: true, // drift
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const out = JSON.parse(
|
|
376
|
+
runSettings([
|
|
377
|
+
"--apply-settings",
|
|
378
|
+
"--consumer-repo",
|
|
379
|
+
"dsj1984/athportal",
|
|
380
|
+
"--baseline",
|
|
381
|
+
join(settingsDir, "baseline.json"),
|
|
382
|
+
"--json",
|
|
383
|
+
])
|
|
384
|
+
);
|
|
385
|
+
assert.equal(out.applied, true);
|
|
386
|
+
assert.equal(out.mismatches.length, 3);
|
|
387
|
+
|
|
388
|
+
const calls = readFileSync(fakeGhLogPath, "utf8")
|
|
389
|
+
.trim()
|
|
390
|
+
.split("\n")
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.map((l) => JSON.parse(l));
|
|
393
|
+
const patchCalls = calls.filter((c) => c[1] === "-X" && c[2] === "PATCH");
|
|
394
|
+
assert.equal(patchCalls.length, 2, "one PATCH for the repo endpoint, one for the Actions endpoint");
|
|
395
|
+
const repoPatch = patchCalls.find((c) => c[3] === "repos/dsj1984/athportal");
|
|
396
|
+
const actionsPatch = patchCalls.find((c) => c[3] === "repos/dsj1984/athportal/actions/permissions/workflow");
|
|
397
|
+
assert.ok(repoPatch, "repo-settings PATCH issued");
|
|
398
|
+
assert.ok(actionsPatch, "Actions-permissions PATCH issued");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("--apply-settings --dry-run reports the plan without issuing any PATCH", () => {
|
|
402
|
+
writeFakeGh({
|
|
403
|
+
"repos/dsj1984/domio": {
|
|
404
|
+
allow_squash_merge: true,
|
|
405
|
+
allow_merge_commit: false,
|
|
406
|
+
allow_rebase_merge: false,
|
|
407
|
+
squash_merge_commit_title: "PR_TITLE",
|
|
408
|
+
squash_merge_commit_message: "PR_BODY",
|
|
409
|
+
delete_branch_on_merge: true,
|
|
410
|
+
allow_auto_merge: true,
|
|
411
|
+
},
|
|
412
|
+
"repos/dsj1984/domio/actions/permissions/workflow": {
|
|
413
|
+
default_workflow_permissions: "write", // drift
|
|
414
|
+
can_approve_pull_request_reviews: false,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
const out = JSON.parse(
|
|
418
|
+
runSettings([
|
|
419
|
+
"--apply-settings",
|
|
420
|
+
"--dry-run",
|
|
421
|
+
"--consumer-repo",
|
|
422
|
+
"dsj1984/domio",
|
|
423
|
+
"--baseline",
|
|
424
|
+
join(settingsDir, "baseline.json"),
|
|
425
|
+
"--json",
|
|
426
|
+
])
|
|
427
|
+
);
|
|
428
|
+
assert.equal(out.dryRun, true);
|
|
429
|
+
assert.equal(out.applied, false, "dry-run never applies");
|
|
430
|
+
const calls = readFileSync(fakeGhLogPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
431
|
+
assert.ok(
|
|
432
|
+
calls.every((c) => !(c[1] === "-X" && c[2] === "PATCH")),
|
|
433
|
+
"no PATCH issued under --dry-run"
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("--check-settings requires --consumer-repo", () => {
|
|
438
|
+
assert.throws(() => {
|
|
439
|
+
execFileSync("node", [CLI, "--check-settings"], { encoding: "utf8" });
|
|
440
|
+
}, /consumer-repo/);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// --check-ruleset (Story #178) — report-only branch-ruleset drift check.
|
|
445
|
+
//
|
|
446
|
+
// Same fake-`gh`-on-PATH harness as --check-settings above. The pure
|
|
447
|
+
// diff/classification logic is unit-tested in check-ruleset.test.mjs; this
|
|
448
|
+
// suite exercises platform-sync's own argument wiring and the (deliberate)
|
|
449
|
+
// absence of any mutating call — there is no --apply-ruleset.
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
const MAIN_PROTECTION_CONTRACT = {
|
|
453
|
+
branch: "main",
|
|
454
|
+
requiredStatusChecks: ["ci-required"],
|
|
455
|
+
requireLinearHistory: false,
|
|
456
|
+
allowForcePushes: false,
|
|
457
|
+
allowDeletions: false,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
function compliantRulesetPayload() {
|
|
461
|
+
return {
|
|
462
|
+
id: 1,
|
|
463
|
+
enforcement: "active",
|
|
464
|
+
conditions: { ref_name: { include: ["refs/heads/main"] } },
|
|
465
|
+
bypass_actors: [],
|
|
466
|
+
rules: [
|
|
467
|
+
{ type: "pull_request" },
|
|
468
|
+
{
|
|
469
|
+
type: "required_status_checks",
|
|
470
|
+
parameters: {
|
|
471
|
+
required_status_checks: [{ context: "ci-required" }],
|
|
472
|
+
strict_required_status_checks_policy: true,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
{ type: "non_fast_forward" },
|
|
476
|
+
{ type: "deletion" },
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("--check-ruleset reports no drift when the live ruleset matches the contract", () => {
|
|
482
|
+
writeFakeGh({
|
|
483
|
+
"repos/Beestera/swarm-os/rulesets": [{ id: 1 }],
|
|
484
|
+
"repos/Beestera/swarm-os/rulesets/1": compliantRulesetPayload(),
|
|
485
|
+
});
|
|
486
|
+
writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
|
|
487
|
+
const out = JSON.parse(
|
|
488
|
+
runSettings([
|
|
489
|
+
"--check-ruleset",
|
|
490
|
+
"--consumer-repo",
|
|
491
|
+
"Beestera/swarm-os",
|
|
492
|
+
"--contract",
|
|
493
|
+
join(settingsDir, "contract.json"),
|
|
494
|
+
"--json",
|
|
495
|
+
])
|
|
496
|
+
);
|
|
497
|
+
assert.equal(out.mode, "check-ruleset");
|
|
498
|
+
assert.equal(out.drift, false);
|
|
499
|
+
assert.deepEqual(out.mismatches, []);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("--check-ruleset reports drift (bypass actor added) and issues no mutating call", () => {
|
|
503
|
+
writeFakeGh({
|
|
504
|
+
"repos/dsj1984/domio/rulesets": [{ id: 7 }],
|
|
505
|
+
"repos/dsj1984/domio/rulesets/7": { ...compliantRulesetPayload(), bypass_actors: [{ actor_id: 1 }] },
|
|
506
|
+
});
|
|
507
|
+
writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
|
|
508
|
+
const out = JSON.parse(
|
|
509
|
+
runSettings([
|
|
510
|
+
"--check-ruleset",
|
|
511
|
+
"--consumer-repo",
|
|
512
|
+
"dsj1984/domio",
|
|
513
|
+
"--contract",
|
|
514
|
+
join(settingsDir, "contract.json"),
|
|
515
|
+
"--json",
|
|
516
|
+
])
|
|
517
|
+
);
|
|
518
|
+
assert.equal(out.drift, true);
|
|
519
|
+
assert.deepEqual(out.mismatches, [{ field: "bypassActorsEmpty", expected: true, actual: false }]);
|
|
520
|
+
const calls = readFileSync(fakeGhLogPath, "utf8").trim().split("\n").filter(Boolean);
|
|
521
|
+
assert.ok(
|
|
522
|
+
calls.every((line) => !JSON.parse(line).includes("PATCH")),
|
|
523
|
+
"--check-ruleset never issues a PATCH — report-only, no --apply-ruleset exists"
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("--check-ruleset never fails the exit code on drift (non-blocking, standing decision #10)", () => {
|
|
528
|
+
writeFakeGh({
|
|
529
|
+
"repos/dsj1984/athportal/rulesets": [{ id: 1 }],
|
|
530
|
+
"repos/dsj1984/athportal/rulesets/1": {
|
|
531
|
+
...compliantRulesetPayload(),
|
|
532
|
+
rules: compliantRulesetPayload().rules.filter((r) => r.type !== "non_fast_forward"),
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
|
|
536
|
+
assert.doesNotThrow(() => {
|
|
537
|
+
runSettings([
|
|
538
|
+
"--check-ruleset",
|
|
539
|
+
"--consumer-repo",
|
|
540
|
+
"dsj1984/athportal",
|
|
541
|
+
"--contract",
|
|
542
|
+
join(settingsDir, "contract.json"),
|
|
543
|
+
"--json",
|
|
544
|
+
]);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("--check-ruleset reports 'missing' when no active ruleset targets the branch", () => {
|
|
549
|
+
writeFakeGh({
|
|
550
|
+
"repos/owner/no-ruleset/rulesets": [],
|
|
551
|
+
});
|
|
552
|
+
writeFileSync(join(settingsDir, "contract.json"), JSON.stringify(MAIN_PROTECTION_CONTRACT));
|
|
553
|
+
const out = JSON.parse(
|
|
554
|
+
runSettings([
|
|
555
|
+
"--check-ruleset",
|
|
556
|
+
"--consumer-repo",
|
|
557
|
+
"owner/no-ruleset",
|
|
558
|
+
"--contract",
|
|
559
|
+
join(settingsDir, "contract.json"),
|
|
560
|
+
"--json",
|
|
561
|
+
])
|
|
562
|
+
);
|
|
563
|
+
assert.equal(out.status, "missing");
|
|
564
|
+
assert.equal(out.drift, true);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("--check-ruleset requires --consumer-repo", () => {
|
|
568
|
+
assert.throws(() => {
|
|
569
|
+
execFileSync("node", [CLI, "--check-ruleset"], { encoding: "utf8" });
|
|
570
|
+
}, /consumer-repo/);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("apply materializes the canonical deploy-staging.yml workflow caller template", () => {
|
|
574
|
+
const out = JSON.parse(run([]));
|
|
575
|
+
const stub = join(consumer, ".github", "workflows", "deploy-staging.yml");
|
|
576
|
+
assert.ok(existsSync(stub));
|
|
577
|
+
assert.ok(
|
|
578
|
+
out.workflowStubs.created.some((f) => f.endsWith("deploy-staging.yml")),
|
|
579
|
+
"deploy-staging.yml reported as created"
|
|
580
|
+
);
|
|
581
|
+
const body = readFileSync(stub, "utf8");
|
|
582
|
+
assert.ok(
|
|
583
|
+
body.includes("Canonical staging-deploy caller template"),
|
|
584
|
+
"materialized workflow carries the template marker"
|
|
585
|
+
);
|
|
586
|
+
assert.ok(
|
|
587
|
+
body.includes("dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml"),
|
|
588
|
+
"template calls the shared deploy-cloudflare.yml reusable workflow"
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("workflow caller template materialization is idempotent", () => {
|
|
593
|
+
run([]); // materialize
|
|
594
|
+
const out = JSON.parse(run([])); // second pass
|
|
595
|
+
assert.ok(
|
|
596
|
+
out.workflowStubs.skipped.some((f) => f.endsWith("deploy-staging.yml")),
|
|
597
|
+
"already-materialized template is skipped, not re-created"
|
|
598
|
+
);
|
|
599
|
+
assert.equal(out.workflowStubs.created.length, 0);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("a hand-authored deploy-staging.yml is flagged, not overwritten", () => {
|
|
603
|
+
const destDir = join(consumer, ".github", "workflows");
|
|
604
|
+
mkdirSync(destDir, { recursive: true });
|
|
605
|
+
const handAuthored = "name: deploy-staging\n# fully custom, no template marker\njobs: {}\n";
|
|
606
|
+
writeFileSync(join(destDir, "deploy-staging.yml"), handAuthored);
|
|
607
|
+
const out = JSON.parse(run([]));
|
|
608
|
+
assert.ok(
|
|
609
|
+
out.workflowStubs.localCopies.some((f) => f.endsWith("deploy-staging.yml")),
|
|
610
|
+
"hand-authored caller surfaced as a warning"
|
|
611
|
+
);
|
|
612
|
+
assert.equal(
|
|
613
|
+
readFileSync(join(destDir, "deploy-staging.yml"), "utf8"),
|
|
614
|
+
handAuthored,
|
|
615
|
+
"operator's hand-authored caller is never clobbered"
|
|
616
|
+
);
|
|
617
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
name: deploy-staging
|
|
2
|
+
|
|
3
|
+
# Canonical staging-deploy caller template (Story #175).
|
|
4
|
+
#
|
|
5
|
+
# > **Thin local caller.** The defence-in-depth deploy core (secret-isolation
|
|
6
|
+
# > audit -> CF env gate -> pre-migration snapshot -> migrate -> deploy ->
|
|
7
|
+
# > boot-smoke + auto-rollback) AND the CI-green guard both live in the shared
|
|
8
|
+
# > `dsj1984/mandrel-platform` `deploy-cloudflare.yml` reusable workflow — see
|
|
9
|
+
# > https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml.
|
|
10
|
+
# > This file only holds <PROJECT_NAME>-specific values (worker names, build
|
|
11
|
+
# > step, secret mapping). When the deploy PROCESS changes, that change lands
|
|
12
|
+
# > upstream in mandrel-platform — not here.
|
|
13
|
+
#
|
|
14
|
+
# One paved road (operator decision 2026-07-01, D4): every consumer triggers
|
|
15
|
+
# staging deploy via `workflow_run` on its own CI workflow, gated on
|
|
16
|
+
# `conclusion == 'success'`. `workflow_run` fires on BOTH a successful AND a
|
|
17
|
+
# failed upstream run, so a caller-side guard against a red run used to be
|
|
18
|
+
# REQUIRED here — every consumer hand-copied the same `preflight` job (see
|
|
19
|
+
# mandrel-platform Story #175 context). That guard is now a `require-ci-green`
|
|
20
|
+
# job INSIDE `deploy-cloudflare.yml` itself (`github.event` inside a reusable
|
|
21
|
+
# workflow is the CALLER's event, so the shared workflow can see and gate on
|
|
22
|
+
# the `workflow_run` conclusion even though it cannot own this file's `on:`
|
|
23
|
+
# block). This template needs NO caller-side preflight guard as a result —
|
|
24
|
+
# copy it as-is and fill in the placeholders below.
|
|
25
|
+
#
|
|
26
|
+
# Replace every <PLACEHOLDER> with your project's real values:
|
|
27
|
+
# <CI_WORKFLOW_NAME> the `name:` of the workflow this deploy should
|
|
28
|
+
# gate on (e.g. "quality", "CI", "PR Quality").
|
|
29
|
+
# Must match EXACTLY — GitHub matches
|
|
30
|
+
# `workflow_run.workflows` by workflow name, not
|
|
31
|
+
# file path.
|
|
32
|
+
# <MANDREL_PLATFORM_SHA> the pinned mandrel-platform commit SHA (resolve
|
|
33
|
+
# via `node scripts/platform-sync.mjs --ref
|
|
34
|
+
# <release-tag>` from the consumer repo root, or
|
|
35
|
+
# hand-resolve via `git ls-remote`).
|
|
36
|
+
# <MANDREL_PLATFORM_TAG> the human-readable release tag matching the SHA
|
|
37
|
+
# above (trailing `# <tag>` comment).
|
|
38
|
+
# <WORKERS_CSV> comma-separated Worker names for this env, e.g.
|
|
39
|
+
# "api,web".
|
|
40
|
+
# <BUILD_COMMAND> optional build command (omit build-command /
|
|
41
|
+
# build-artifact entirely if the deploy job's
|
|
42
|
+
# default checkout is build-ready).
|
|
43
|
+
#
|
|
44
|
+
# See the full input/secret contract:
|
|
45
|
+
# https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml
|
|
46
|
+
|
|
47
|
+
on:
|
|
48
|
+
# CI-green gate: fires when <CI_WORKFLOW_NAME> finishes on main. The shared
|
|
49
|
+
# deploy-cloudflare.yml's require-ci-green job skips-with-notice unless the
|
|
50
|
+
# upstream conclusion was 'success' — no caller-side guard needed.
|
|
51
|
+
workflow_run:
|
|
52
|
+
workflows: [<CI_WORKFLOW_NAME>]
|
|
53
|
+
branches: [main]
|
|
54
|
+
types: [completed]
|
|
55
|
+
# Manual on-demand trigger (UI "Run workflow" + `gh workflow run`).
|
|
56
|
+
# workflow_dispatch always passes the shared workflow's CI-green guard
|
|
57
|
+
# (operator-intentional, no upstream conclusion to gate on).
|
|
58
|
+
workflow_dispatch:
|
|
59
|
+
|
|
60
|
+
permissions:
|
|
61
|
+
contents: read
|
|
62
|
+
|
|
63
|
+
# Cancel an in-flight staging deploy when a newer commit lands on main — only
|
|
64
|
+
# the freshest tip of main should reach the staging surfaces. The shared
|
|
65
|
+
# deploy-cloudflare.yml additionally serializes per-environment.
|
|
66
|
+
concurrency:
|
|
67
|
+
group: deploy-staging
|
|
68
|
+
cancel-in-progress: true
|
|
69
|
+
|
|
70
|
+
jobs:
|
|
71
|
+
deploy:
|
|
72
|
+
name: Staging deploy (shared deploy-cloudflare.yml)
|
|
73
|
+
uses: dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
|
|
74
|
+
with:
|
|
75
|
+
environment: staging
|
|
76
|
+
gh-environment: staging
|
|
77
|
+
workers: <WORKERS_CSV>
|
|
78
|
+
migrate: true
|
|
79
|
+
# db-engine defaults to 'd1'. Set db-engine + migrate-command +
|
|
80
|
+
# snapshot-command for a non-D1 engine (e.g. Turso) — see the contract
|
|
81
|
+
# doc's "command seams" section.
|
|
82
|
+
# Frozen secret allowlist: only {CLOUDFLARE_*, TURSO_*} cross into the
|
|
83
|
+
# shared workflow. Map your project's secret NAMES onto these slots.
|
|
84
|
+
secrets:
|
|
85
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
86
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: uptime-apply
|
|
2
|
+
|
|
3
|
+
# Canonical staging-deploy caller template (Story #180).
|
|
4
|
+
#
|
|
5
|
+
# > **Thin local caller.** The Better Stack monitor schema + apply logic
|
|
6
|
+
# > (config validation, live-diff, create/update, graceful skip-with-notice
|
|
7
|
+
# > when the secret isn't provisioned) lives in the shared
|
|
8
|
+
# > `dsj1984/mandrel-platform` `uptime-apply.yml` reusable workflow — see
|
|
9
|
+
# > https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#uptime-applyyml.
|
|
10
|
+
# > This file only holds <PROJECT_NAME>-specific values (the monitor-config
|
|
11
|
+
# > path and the apply trigger). When the apply PROCESS changes, that change
|
|
12
|
+
# > lands upstream in mandrel-platform — not here.
|
|
13
|
+
#
|
|
14
|
+
# Replace every <PLACEHOLDER> with your project's real values:
|
|
15
|
+
# <MANDREL_PLATFORM_SHA> the pinned mandrel-platform commit SHA (resolve
|
|
16
|
+
# via `node scripts/platform-sync.mjs --ref
|
|
17
|
+
# <release-tag>` from the consumer repo root, or
|
|
18
|
+
# hand-resolve via `git ls-remote`).
|
|
19
|
+
# <MANDREL_PLATFORM_TAG> the human-readable release tag matching the SHA
|
|
20
|
+
# above (trailing `# <tag>` comment).
|
|
21
|
+
# <MONITOR_CONFIG_PATH> path to this repo's monitor-config JSON, e.g.
|
|
22
|
+
# "infra/uptime/monitors.json".
|
|
23
|
+
#
|
|
24
|
+
# See the full input/secret contract:
|
|
25
|
+
# https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#uptime-applyyml
|
|
26
|
+
|
|
27
|
+
on:
|
|
28
|
+
push:
|
|
29
|
+
branches: [main]
|
|
30
|
+
workflow_dispatch:
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
|
|
35
|
+
concurrency:
|
|
36
|
+
group: uptime-apply
|
|
37
|
+
cancel-in-progress: false
|
|
38
|
+
|
|
39
|
+
jobs:
|
|
40
|
+
uptime:
|
|
41
|
+
name: Uptime apply (shared uptime-apply.yml)
|
|
42
|
+
uses: dsj1984/mandrel-platform/.github/workflows/uptime-apply.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
|
|
43
|
+
with:
|
|
44
|
+
monitor-config: <MONITOR_CONFIG_PATH>
|
|
45
|
+
# apply:'true' on push to main converges live Better Stack monitors to
|
|
46
|
+
# the checked-in config; a workflow_dispatch preview run can pass
|
|
47
|
+
# apply:'false' instead to dry-run without writing.
|
|
48
|
+
apply: ${{ github.event_name == 'push' && 'true' || 'false' }}
|
|
49
|
+
# Frozen secret surface: only these two cross into the shared workflow.
|
|
50
|
+
# Both are optional on the shared side — an absent BETTERSTACK_API_TOKEN
|
|
51
|
+
# is the documented graceful-degradation (skip-with-notice) path.
|
|
52
|
+
secrets:
|
|
53
|
+
BETTERSTACK_API_TOKEN: ${{ secrets.BETTERSTACK_API_TOKEN }}
|
|
54
|
+
UPTIME_ALERT_EMAIL: ${{ secrets.UPTIME_ALERT_EMAIL }}
|