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.
@@ -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
- // Only run when executed directly, not when imported by the test suite.
342
- const invokedDirectly =
343
- process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
344
- if (invokedDirectly) {
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: 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.
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 }}