mandrel-platform 1.4.1 → 1.5.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/package.json +2 -2
- package/scripts/apply-uptime-monitors.mjs +89 -21
- package/scripts/apply-uptime-monitors.test.mjs +196 -19
- package/scripts/check-affected-mode.test.mjs +54 -1
- package/scripts/check-coverage-threshold.test.mjs +1 -1
- package/scripts/check-destructive-migration.mjs +6 -2
- package/scripts/check-osv-scan-mode.test.mjs +2 -2
- package/scripts/check-pin-drift.mjs +7 -5
- package/scripts/check-repo-settings.mjs +6 -4
- package/scripts/check-ruleset.mjs +6 -4
- package/scripts/check-runner-health.mjs +6 -4
- package/scripts/check-workflow-gh-flags.mjs +6 -2
- package/scripts/check-workflow-platform-checkout.mjs +319 -0
- package/scripts/check-workflow-platform-checkout.test.mjs +342 -0
- package/scripts/check-wrangler-baseline.mjs +126 -8
- package/scripts/check-wrangler-baseline.test.mjs +258 -1
- package/scripts/deploy-boot-smoke.mjs +1 -1
- package/scripts/deploy-worker-secrets.mjs +1 -1
- package/scripts/lib/entry-guard.mjs +111 -0
- package/scripts/lib/entry-guard.test.mjs +179 -0
- package/scripts/platform-repair.mjs +6 -4
- package/scripts/track-issue.test.mjs +280 -0
- package/scripts/update-semgrep-rules.mjs +6 -4
- package/templates/workflows/uptime-apply.yml +10 -4
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
14
17
|
|
|
15
18
|
import {
|
|
16
19
|
DEFAULT_CLOSE_COMMENT,
|
|
@@ -25,8 +28,14 @@ import {
|
|
|
25
28
|
findTrackingIssue,
|
|
26
29
|
markerKey,
|
|
27
30
|
markerLine,
|
|
31
|
+
main,
|
|
32
|
+
parseIssueNumberFromUrl,
|
|
33
|
+
planTrackerRun,
|
|
28
34
|
renderEnvEntry,
|
|
29
35
|
resolveConfig,
|
|
36
|
+
resolveTrackerOutputs,
|
|
37
|
+
writeGithubEnv,
|
|
38
|
+
writeGithubOutput,
|
|
30
39
|
} from "../.github/actions/track-issue/track-issue.mjs";
|
|
31
40
|
|
|
32
41
|
const MARKER = "acme:nightly-tracker";
|
|
@@ -373,3 +382,274 @@ test("findTrackingIssue surfaces a gh failure rather than reporting no issue", (
|
|
|
373
382
|
/gh issue list failed/,
|
|
374
383
|
);
|
|
375
384
|
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Composite outputs (Story #412)
|
|
388
|
+
//
|
|
389
|
+
// The tracker's whole purpose is to notify someone, and a caller that wants to
|
|
390
|
+
// assign or link the issue previously had to re-discover it with a `gh issue
|
|
391
|
+
// list` that races the create this action just made. These pin the pure
|
|
392
|
+
// verdict → output mapping that removes the race.
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
/** The public vocabulary, written as literals — never derived from the module
|
|
396
|
+
* under test, or a rename would silently redefine the contract it asserts. */
|
|
397
|
+
const PUBLIC_ACTIONS = ["opened", "updated", "closed", "noop"];
|
|
398
|
+
|
|
399
|
+
test("every state-table row maps to its public action and the right number", () => {
|
|
400
|
+
const existing = { number: 4242, body: "…" };
|
|
401
|
+
|
|
402
|
+
// create → opened, number parsed from the URL gh printed.
|
|
403
|
+
assert.deepEqual(
|
|
404
|
+
resolveTrackerOutputs({
|
|
405
|
+
verdict: { action: "create" },
|
|
406
|
+
existing: null,
|
|
407
|
+
createdUrl: "https://github.com/acme/app/issues/77\n",
|
|
408
|
+
}),
|
|
409
|
+
{ issueNumber: "77", actionTaken: "opened" },
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// update → updated, the live issue's number.
|
|
413
|
+
assert.deepEqual(resolveTrackerOutputs({ verdict: { action: "update" }, existing }), {
|
|
414
|
+
issueNumber: "4242",
|
|
415
|
+
actionTaken: "updated",
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// close → closed. The number still reports: the caller may want to link the
|
|
419
|
+
// issue it just closed.
|
|
420
|
+
assert.deepEqual(resolveTrackerOutputs({ verdict: { action: "close" }, existing }), {
|
|
421
|
+
issueNumber: "4242",
|
|
422
|
+
actionTaken: "closed",
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// same-digest noop → noop, WITH the number. This is the load-bearing row:
|
|
426
|
+
// without it, "issue exists, set unchanged" is indistinguishable from
|
|
427
|
+
// "nothing failing, no issue" — the exact ambiguity the outputs remove.
|
|
428
|
+
assert.deepEqual(resolveTrackerOutputs({ verdict: { action: "noop" }, existing }), {
|
|
429
|
+
issueNumber: "4242",
|
|
430
|
+
actionTaken: "noop",
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// empty-set-with-no-issue noop → noop, and empty is the ONLY case that is.
|
|
434
|
+
assert.deepEqual(resolveTrackerOutputs({ verdict: { action: "noop" }, existing: null }), {
|
|
435
|
+
issueNumber: "",
|
|
436
|
+
actionTaken: "noop",
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("action-taken only ever emits the four public past-tense values", () => {
|
|
441
|
+
for (const action of ["create", "update", "close", "noop"]) {
|
|
442
|
+
const { actionTaken } = resolveTrackerOutputs({
|
|
443
|
+
verdict: { action },
|
|
444
|
+
existing: { number: 1 },
|
|
445
|
+
createdUrl: "https://github.com/acme/app/issues/1",
|
|
446
|
+
});
|
|
447
|
+
assert.ok(
|
|
448
|
+
PUBLIC_ACTIONS.includes(actionTaken),
|
|
449
|
+
`${action} produced ${actionTaken}, which is not a public output value`,
|
|
450
|
+
);
|
|
451
|
+
// The internal imperative verdict names must not leak out as-is; only
|
|
452
|
+
// `noop` is deliberately spelled the same in both vocabularies.
|
|
453
|
+
if (action !== "noop") assert.notEqual(actionTaken, action);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// An unrecognised verdict degrades to the quietest value rather than
|
|
457
|
+
// emitting a fifth word a caller's `if:` has never heard of.
|
|
458
|
+
assert.equal(resolveTrackerOutputs({ verdict: { action: "reopen" } }).actionTaken, "noop");
|
|
459
|
+
assert.equal(resolveTrackerOutputs({}).actionTaken, "noop");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("the created issue number is parsed from the gh issue create URL", () => {
|
|
463
|
+
assert.equal(parseIssueNumberFromUrl("https://github.com/acme/app/issues/512\n"), "512");
|
|
464
|
+
// gh is free to print chatter before the URL; the URL is always last.
|
|
465
|
+
assert.equal(
|
|
466
|
+
parseIssueNumberFromUrl("Creating issue in acme/app\nhttps://github.com/acme/app/issues/9"),
|
|
467
|
+
"9",
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("an unparseable create URL yields opened with an empty number, never a throw", () => {
|
|
472
|
+
// A successful create whose URL we cannot read is degraded, not failed — the
|
|
473
|
+
// issue exists, so throwing here would discard a real write.
|
|
474
|
+
for (const payload of ["", " ", "not a url", "https://github.com/acme/app/pull/3", null]) {
|
|
475
|
+
const outputs = resolveTrackerOutputs({
|
|
476
|
+
verdict: { action: "create" },
|
|
477
|
+
existing: null,
|
|
478
|
+
createdUrl: payload,
|
|
479
|
+
});
|
|
480
|
+
assert.deepEqual(outputs, { issueNumber: "", actionTaken: "opened" });
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("writeGithubOutput reuses renderEnvEntry's heredoc escaping", () => {
|
|
485
|
+
const dir = mkdtempSync(join(tmpdir(), "track-issue-out-"));
|
|
486
|
+
const file = join(dir, "gh-output");
|
|
487
|
+
const entries = [
|
|
488
|
+
["issue-number", "77"],
|
|
489
|
+
["action-taken", "opened"],
|
|
490
|
+
["detail", "line one\nline two"],
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
writeGithubOutput(entries, file);
|
|
494
|
+
|
|
495
|
+
assert.equal(
|
|
496
|
+
readFileSync(file, "utf8"),
|
|
497
|
+
entries.map(([k, v]) => renderEnvEntry(k, v)).join(""),
|
|
498
|
+
);
|
|
499
|
+
// The multi-line value must be the heredoc form, not a truncated KEY=value.
|
|
500
|
+
assert.match(readFileSync(file, "utf8"), /detail<<detail_EOF_7f3a\nline one\nline two\n/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("an unset GITHUB_OUTPUT skips the write and returns, where GITHUB_ENV throws", () => {
|
|
504
|
+
// Outputs are additive — a caller that ignores them is the normal case — so
|
|
505
|
+
// a missing $GITHUB_OUTPUT must never fail an otherwise-healthy tracker run.
|
|
506
|
+
for (const missing of [undefined, "", null]) {
|
|
507
|
+
assert.doesNotThrow(() => writeGithubOutput([["issue-number", "77"]], missing));
|
|
508
|
+
assert.equal(writeGithubOutput([["issue-number", "77"]], missing), undefined);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The asymmetry is deliberate: the next step of the composite cannot run
|
|
512
|
+
// without the $GITHUB_ENV hand-off, so that one still fails loudly.
|
|
513
|
+
assert.throws(() => writeGithubEnv([["K", "v"]], undefined), /GITHUB_ENV is not set/);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("a dry run performs no tracker write and still reports the would-be verdict", () => {
|
|
517
|
+
const env = {
|
|
518
|
+
TRACK_MARKER: MARKER,
|
|
519
|
+
TRACK_REPO: "acme/app",
|
|
520
|
+
TRACK_FAILED_ITEMS: JSON.stringify(["a", "b"]),
|
|
521
|
+
};
|
|
522
|
+
const dry = resolveConfig({ ...env, TRACK_DRY_RUN: "true" });
|
|
523
|
+
const live = resolveConfig(env);
|
|
524
|
+
assert.equal(dry.dryRun, true);
|
|
525
|
+
assert.equal(live.dryRun, false);
|
|
526
|
+
|
|
527
|
+
// `main()` branches on planTrackerRun().writesToTracker, and every gh
|
|
528
|
+
// create/edit/close/comment lives beyond that branch — so `false` here IS
|
|
529
|
+
// "no tracker write", asserted rather than merely read off the source.
|
|
530
|
+
const existing = issueWithDigest(31, "stale-digest");
|
|
531
|
+
const verdict = decideVerdict(
|
|
532
|
+
existing,
|
|
533
|
+
{ failedCount: dry.failedItems.length, digest: dry.digest },
|
|
534
|
+
{ digestPrefix: dry.digestPrefix, unchangedBehavior: dry.unchangedBehavior },
|
|
535
|
+
);
|
|
536
|
+
assert.equal(verdict.action, "update");
|
|
537
|
+
|
|
538
|
+
const planned = planTrackerRun(dry, { verdict, existing });
|
|
539
|
+
assert.equal(planned.writesToTracker, false, "a dry run must never reach the gh mutations");
|
|
540
|
+
assert.deepEqual(
|
|
541
|
+
planned.outputs,
|
|
542
|
+
{ issueNumber: "31", actionTaken: "updated" },
|
|
543
|
+
"a dry run is not a quiet run — it reports the verdict it declined to perform",
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// The same inputs without dry-run DO write, so the flag is what gates it.
|
|
547
|
+
assert.equal(planTrackerRun(live, { verdict, existing }).writesToTracker, true);
|
|
548
|
+
|
|
549
|
+
// A dry run over an unchanged set still surfaces the live issue number.
|
|
550
|
+
const unchangedIssue = issueWithDigest(31, dry.digest);
|
|
551
|
+
const unchanged = decideVerdict(
|
|
552
|
+
unchangedIssue,
|
|
553
|
+
{ failedCount: dry.failedItems.length, digest: dry.digest },
|
|
554
|
+
{ digestPrefix: dry.digestPrefix },
|
|
555
|
+
);
|
|
556
|
+
assert.equal(unchanged.action, "noop");
|
|
557
|
+
assert.deepEqual(planTrackerRun(dry, { verdict: unchanged, existing: unchangedIssue }), {
|
|
558
|
+
writesToTracker: false,
|
|
559
|
+
outputs: { issueNumber: "31", actionTaken: "noop" },
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// AC-6 — the dry-run guarantee is BEHAVIOURAL, so it is asserted of main()
|
|
565
|
+
//
|
|
566
|
+
// `planTrackerRun` returning `writesToTracker: false` only proves the plan SAYS
|
|
567
|
+
// not to write. It does not prove main() obeys the plan: replacing the branch
|
|
568
|
+
// with `if (false)` leaves that assertion green while a dry run performs real
|
|
569
|
+
// gh create/edit/close calls. The only way to assert "no tracker write" is to
|
|
570
|
+
// hand main() a runner and observe that no mutation reaches it.
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
/** gh subcommands that WRITE to the tracker. `issue list` is a read. */
|
|
574
|
+
const MUTATIONS = ["create", "edit", "close", "comment"];
|
|
575
|
+
const isMutation = (args) => args[0] === "issue" && MUTATIONS.includes(args[1]);
|
|
576
|
+
|
|
577
|
+
/** A recording `gh` runner that answers the lookup with one marked issue. */
|
|
578
|
+
function fakeRunner(issue) {
|
|
579
|
+
const calls = [];
|
|
580
|
+
const runner = (args) => {
|
|
581
|
+
calls.push(args);
|
|
582
|
+
if (args[0] === "issue" && args[1] === "list") return JSON.stringify(issue ? [issue] : []);
|
|
583
|
+
return "https://github.com/acme/app/issues/999\n";
|
|
584
|
+
};
|
|
585
|
+
runner.calls = calls;
|
|
586
|
+
runner.mutations = () => calls.filter(isMutation);
|
|
587
|
+
return runner;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const dryRunEnv = (githubOutput) => ({
|
|
591
|
+
TRACK_MARKER: MARKER,
|
|
592
|
+
TRACK_REPO: "acme/app",
|
|
593
|
+
TRACK_FAILED_ITEMS: JSON.stringify(["a", "b"]),
|
|
594
|
+
TRACK_DRY_RUN: "true",
|
|
595
|
+
GITHUB_OUTPUT: githubOutput,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("a dry run leaves the injected runner with ZERO mutation calls", () => {
|
|
599
|
+
const file = join(mkdtempSync(join(tmpdir(), "track-issue-dry-")), "gh-output");
|
|
600
|
+
const existing = issueWithDigest(31, "stale-digest");
|
|
601
|
+
const runner = fakeRunner(existing);
|
|
602
|
+
|
|
603
|
+
assert.equal(main(dryRunEnv(file), runner), 0);
|
|
604
|
+
|
|
605
|
+
// The runner IS wired — the lookup reached it — so "no mutations" is a real
|
|
606
|
+
// observation about this run, not a vacuous assertion about a dead seam.
|
|
607
|
+
assert.ok(runner.calls.length >= 1, "the runner never saw the issue lookup");
|
|
608
|
+
assert.deepEqual(runner.calls.filter((a) => a[0] === "issue" && a[1] === "list").length, 1);
|
|
609
|
+
assert.deepEqual(
|
|
610
|
+
runner.mutations(),
|
|
611
|
+
[],
|
|
612
|
+
"a dry run must not create, edit, close or comment on anything",
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// …and it still publishes the verdict it declined to perform.
|
|
616
|
+
assert.equal(readFileSync(file, "utf8"), "issue-number=31\naction-taken=updated\n");
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("the same run without dry-run DOES reach the tracker — the flag is what gates it", () => {
|
|
620
|
+
// The differential: if this did not mutate, the test above would prove
|
|
621
|
+
// nothing about dry-run in particular.
|
|
622
|
+
const file = join(mkdtempSync(join(tmpdir(), "track-issue-live-")), "gh-output");
|
|
623
|
+
const existing = issueWithDigest(31, "stale-digest");
|
|
624
|
+
const runner = fakeRunner(existing);
|
|
625
|
+
const { TRACK_DRY_RUN, ...live } = dryRunEnv(file);
|
|
626
|
+
assert.equal(TRACK_DRY_RUN, "true");
|
|
627
|
+
|
|
628
|
+
assert.equal(main(live, runner), 0);
|
|
629
|
+
|
|
630
|
+
const mutations = runner.mutations();
|
|
631
|
+
assert.equal(mutations.length, 1);
|
|
632
|
+
assert.deepEqual(mutations[0].slice(0, 3), ["issue", "edit", "31"]);
|
|
633
|
+
assert.equal(readFileSync(file, "utf8"), "issue-number=31\naction-taken=updated\n");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("a dry run over an empty failing set never closes the live issue", () => {
|
|
637
|
+
// The most expensive dry-run defect: silently closing a tracked issue whose
|
|
638
|
+
// failures are still real. Asserted on the runner, not on a plan object.
|
|
639
|
+
const file = join(mkdtempSync(join(tmpdir(), "track-issue-dry-")), "gh-output");
|
|
640
|
+
const existing = issueWithDigest(31, "d1");
|
|
641
|
+
const runner = fakeRunner(existing);
|
|
642
|
+
|
|
643
|
+
assert.equal(main({ ...dryRunEnv(file), TRACK_FAILED_ITEMS: "[]" }, runner), 0);
|
|
644
|
+
|
|
645
|
+
assert.deepEqual(runner.mutations(), []);
|
|
646
|
+
assert.equal(readFileSync(file, "utf8"), "issue-number=31\naction-taken=closed\n");
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("a dry run with no GITHUB_OUTPUT still runs clean and writes nothing", () => {
|
|
650
|
+
const runner = fakeRunner(issueWithDigest(31, "stale-digest"));
|
|
651
|
+
const { GITHUB_OUTPUT, ...noOutput } = dryRunEnv("/unused");
|
|
652
|
+
assert.equal(GITHUB_OUTPUT, "/unused");
|
|
653
|
+
assert.equal(main(noOutput, runner), 0);
|
|
654
|
+
assert.deepEqual(runner.mutations(), []);
|
|
655
|
+
});
|
|
@@ -67,6 +67,8 @@ import { tmpdir } from "node:os";
|
|
|
67
67
|
import { dirname, join, resolve } from "node:path";
|
|
68
68
|
import { fileURLToPath } from "node:url";
|
|
69
69
|
|
|
70
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
71
|
+
|
|
70
72
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
71
73
|
const REPO_ROOT = resolve(__dirname, "..");
|
|
72
74
|
|
|
@@ -338,9 +340,9 @@ export function runCli(argv, { log = console.log, err = console.error } = {}) {
|
|
|
338
340
|
return 0;
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
343
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
344
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
345
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
346
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
345
347
|
process.exit(runCli(process.argv.slice(2)));
|
|
346
348
|
}
|
|
@@ -46,9 +46,15 @@ jobs:
|
|
|
46
46
|
# the checked-in config; a workflow_dispatch preview run can pass
|
|
47
47
|
# apply:'false' instead to dry-run without writing.
|
|
48
48
|
apply: ${{ github.event_name == 'push' && 'true' || 'false' }}
|
|
49
|
-
# Frozen secret surface
|
|
50
|
-
#
|
|
51
|
-
#
|
|
49
|
+
# Frozen secret surface. BETTERSTACK_API_TOKEN is optional on the shared
|
|
50
|
+
# side — absent, the apply takes the documented graceful-degradation
|
|
51
|
+
# (skip-with-notice) path and raises a warning annotation.
|
|
52
|
+
#
|
|
53
|
+
# UPTIME_ALERT_EMAIL is deprecated and deliberately not passed here:
|
|
54
|
+
# Better Stack's monitor `email` field is a boolean switch, not a
|
|
55
|
+
# recipient, so the address never routed alerts. Set a monitor entry's
|
|
56
|
+
# `policyId` (escalation policy) to control who is alerted. The shared
|
|
57
|
+
# workflow still declares the secret, so an existing caller that passes
|
|
58
|
+
# it keeps compiling.
|
|
52
59
|
secrets:
|
|
53
60
|
BETTERSTACK_API_TOKEN: ${{ secrets.BETTERSTACK_API_TOKEN }}
|
|
54
|
-
UPTIME_ALERT_EMAIL: ${{ secrets.UPTIME_ALERT_EMAIL }}
|