@plainconceptsplatform/workflows 1.1.1 → 1.2.1

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.
@@ -326,7 +326,11 @@ async function preCommitHookUpdate(repositoryPath) {
326
326
  if (!await exists(hookPath)) {
327
327
  return { target, content: managedLines };
328
328
  }
329
+ // A Windows clone with core.autocrlf checks the hook out with CRLF, which the LF patterns below
330
+ // never match: every update from Windows appended one more block (one consumer had ten). The
331
+ // hook is written back with LF, as every managed file is.
329
332
  const content = (await readFile(hookPath, "utf8"))
333
+ .replace(/\r\n/g, "\n")
330
334
  .replace("pnpm exec if git diff --cached --name-only -- .github | grep -q .; then", "if git diff --cached --name-only -- .github | grep -q .; then");
331
335
  // Replace whatever managed block is there, never append beside it. The previous version
332
336
  // recognised only two exact shapes -- the current block and the pre-`if` legacy lines -- and
@@ -3,7 +3,8 @@ name: Capture evidence
3
3
  description: >
4
4
  Reads a pull request's verification plans (written by the harness skill pc-repo-verify) and,
5
5
  in capture mode, replays them in headless Chromium against the running app, writing one JPEG
6
- per waypoint plus manifest.json and comment.md. Inspect mode only reports whether any plan is
6
+ per waypoint under files/<plan directory>/evidence/, laid out as the repository is, plus
7
+ manifest.json and comment.md. Inspect mode only reports whether any plan is
7
8
  replayable, at which address, and with which start command. No model, no network beyond
8
9
  installing its two pinned packages.
9
10
  inputs:
@@ -20,6 +21,13 @@ inputs:
20
21
  description: Where capture and unreached write the screenshots, manifest.json and comment.md.
21
22
  required: false
22
23
  default: ''
24
+ sign-in:
25
+ description: >
26
+ The repository's own sign-in steps for plans that start signed in (`login:`), relative to the
27
+ workspace, which is the default branch's checkout. The repository owns the file; without it
28
+ such a plan replays signed out and its comment says why.
29
+ required: false
30
+ default: '.github/evidence/sign-in.yml'
23
31
  playwright-version:
24
32
  description: playwright-core version. Must match the mcr.microsoft.com/playwright image tag the capture runs in.
25
33
  required: false
@@ -74,6 +82,7 @@ runs:
74
82
  ROOT: ${{ inputs.root }}
75
83
  PLANS: ${{ inputs.plans }}
76
84
  OUT_DIR: ${{ inputs.out-dir }}
85
+ EVIDENCE_SIGN_IN: ${{ inputs.sign-in }}
77
86
  PLAYWRIGHT_VERSION: ${{ inputs.playwright-version }}
78
87
  # Chromium's own stderr. A browser that dies on launch says why only here.
79
88
  DEBUG: pw:browser
@@ -101,7 +110,7 @@ runs:
101
110
  if command -v docker > /dev/null 2>&1; then
102
111
  # The host network, so the browser reaches the app on localhost where it started;
103
112
  # the runner's own user, so what it writes the publish job can read.
104
- docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp -e DEBUG -e EVIDENCE_SHA -e EVIDENCE_RUN_URL -e EVIDENCE_STEP_TIMEOUT_MS -e EVIDENCE_NAVIGATION_TIMEOUT_MS -v "$runner:$runner:ro" -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" -v "$OUT_DIR:$OUT_DIR" -w "$GITHUB_WORKSPACE" "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
113
+ docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp -e DEBUG -e EVIDENCE_SHA -e EVIDENCE_RUN_URL -e EVIDENCE_SIGN_IN -e EVIDENCE_STEP_TIMEOUT_MS -e EVIDENCE_NAVIGATION_TIMEOUT_MS -v "$runner:$runner:ro" -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" -v "$OUT_DIR:$OUT_DIR" -w "$GITHUB_WORKSPACE" "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
105
114
  else
106
115
  node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
107
116
  fi
@@ -8,7 +8,10 @@
8
8
  // prints {"runnable","url","start","plans"} as JSON, so the workflow knows whether to start
9
9
  // the app at all and which address to wait for
10
10
  // node capture.mjs capture <repo-root> <out-dir> <plan.md>...
11
- // writes NN-<capture>.jpg per waypoint plus manifest.json and comment.md into <out-dir>
11
+ // writes one screenshot per waypoint under <out-dir>/files/, laid out as the repository is:
12
+ // <plan's directory>/evidence/NN-<capture>.jpg beside a manifest.json for that plan, so the
13
+ // publish job copies files/ over the default branch as is. Writes the run's manifest.json and
14
+ // comment.md into <out-dir>
12
15
  // node capture.mjs unreached <repo-root> <out-dir> <plan.md>...
13
16
  // the app never answered: writes manifest.json and comment.md saying so, and nothing else
14
17
  //
@@ -16,7 +19,7 @@
16
19
  // imports are bare: ESM resolves them from the file's own directory, never from NODE_PATH.
17
20
 
18
21
  import { mkdir, readFile, writeFile } from "node:fs/promises";
19
- import { basename, dirname, join } from "node:path";
22
+ import { basename, dirname, join, posix } from "node:path";
20
23
  import { pathToFileURL } from "node:url";
21
24
 
22
25
  const DEFAULT_URL = "http://localhost:3000";
@@ -27,6 +30,17 @@ const NAVIGATION_TIMEOUT_MS = Number(process.env.EVIDENCE_NAVIGATION_TIMEOUT_MS
27
30
  // is the commit that job pushes, so the comment is written here and the publish job fills it in.
28
31
  export const IMAGE_BASE = "__EVIDENCE_BASE__";
29
32
  export const MARKER = "<!-- agentic-evidence -->";
33
+ // Opens the machine-readable copy of the manifest at the end of the comment, which is what other
34
+ // tools read (Project Health shows the screenshots with each issue) instead of parsing markdown.
35
+ export const EMBED_MARKER = "agentic-evidence-manifest";
36
+ // The folder beside each verification plan that keeps its screenshots.
37
+ export const EVIDENCE_DIR = "evidence";
38
+ // Stays well under GitHub's 65,536 character comment limit with the markdown above it.
39
+ const EMBED_LIMIT = 30000;
40
+ // The repository's own steps for signing in locally, read from the default branch so a replay of
41
+ // an older pull request finds them too. The repository owns the file: an update never writes it.
42
+ export const SIGN_IN_FILE = ".github/evidence/sign-in.yml";
43
+ const ACCOUNT_PLACEHOLDER = "{account}";
30
44
 
31
45
  const ACTIONS = new Set(["open", "click", "dblclick", "fill", "press", "select", "check", "uncheck", "hover", "wait"]);
32
46
  const LOCATOR_KEYS = ["role", "text", "label", "placeholder", "testid", "alt", "title", "css"];
@@ -44,6 +58,17 @@ export function slug(value) {
44
58
  return text.slice(0, 60) || "waypoint";
45
59
  }
46
60
 
61
+ // The directory a plan lives in, which is where its screenshots are kept. The path comes from the
62
+ // pull request's file list, so it is repository-relative; anything that could climb out of the
63
+ // repository is refused rather than written.
64
+ export function planDir(planPath) {
65
+ const dir = posix.dirname(String(planPath).replace(/\\/g, "/"));
66
+ if (dir === "." || dir.startsWith("/") || dir.split("/").includes("..")) {
67
+ throw new Error(`unsafe plan path ${planPath}`);
68
+ }
69
+ return dir;
70
+ }
71
+
47
72
  // The change a plan belongs to, from its directory. Archived changes carry a date prefix.
48
73
  export function changeName(planPath) {
49
74
  return basename(dirname(planPath)).replace(/^\d{4}-\d{2}-\d{2}-/, "") || "change";
@@ -108,15 +133,83 @@ export function normalizePlan(doc) {
108
133
  return { n: index + 1, capture, expect: String(raw.expect ?? "").trim(), steps, ignored };
109
134
  });
110
135
 
136
+ const login = parseLogin(doc.login ?? environment.login, environment);
111
137
  return {
112
138
  status: "runnable",
113
139
  url: String(doc.url ?? environment.url ?? DEFAULT_URL).trim() || DEFAULT_URL,
114
140
  start: String(doc.start ?? environment.start ?? "").trim(),
115
141
  viewport: parseViewport(doc.viewport ?? environment.viewport),
142
+ ...(login ? { login } : {}),
116
143
  waypoints,
117
144
  };
118
145
  }
119
146
 
147
+ // A plan that starts signed in says so with `login`, rather than signing in in its first
148
+ // waypoint. The plans written before that waypoint signed in name a mechanism (`login: mock-sso`)
149
+ // and say whom in prose ("Sign in as Bob Builder"), so the prose travels along as a hint. A plan
150
+ // can also name the account (`login: { account: Bob Builder }`) or spell out its own steps.
151
+ export function parseLogin(value, environment = {}) {
152
+ if (value === undefined || value === null || value === false) return undefined;
153
+ if (Array.isArray(value)) return { steps: value };
154
+ if (isObject(value) && Array.isArray(value.steps)) return { steps: value.steps };
155
+ if (isObject(value) && value.account !== undefined) return { account: String(value.account) };
156
+ const hint = Object.values(environment).filter((text) => typeof text === "string").join("\n");
157
+ return { hint };
158
+ }
159
+
160
+ // The account a plan's prose asks for: one of the repository's accounts right after "sign in as",
161
+ // the earliest when the prose names several. Only an account the repository lists can match.
162
+ export function accountNamed(text, accounts) {
163
+ let best;
164
+ for (const account of accounts) {
165
+ const escaped = account.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+ const at = String(text ?? "").search(new RegExp(`sign(?:ed)?[\\s-]*in\\s+as\\s+(?:the\\s+)?${escaped}\\b`, "i"));
167
+ if (at >= 0 && (best === undefined || at < best.at)) best = { account, at };
168
+ }
169
+ return best?.account;
170
+ }
171
+
172
+ // Reads the repository's sign-in: the steps, in the journey's own step syntax, with `{account}`
173
+ // where the account goes, and the accounts a plan can ask for, the first being the default.
174
+ export function parseSignIn(text, parseYaml) {
175
+ const doc = parseYaml(String(text));
176
+ if (!isObject(doc) || !Array.isArray(doc.steps) || doc.steps.length === 0) {
177
+ throw new Error("the sign-in needs a steps list");
178
+ }
179
+ return { accounts: asArray(doc.accounts).map((account) => String(account)), steps: doc.steps };
180
+ }
181
+
182
+ const fillAccount = (value, account) =>
183
+ typeof value === "string"
184
+ ? value.split(ACCOUNT_PLACEHOLDER).join(account)
185
+ : Array.isArray(value)
186
+ ? value.map((item) => fillAccount(item, account))
187
+ : isObject(value)
188
+ ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillAccount(item, account)]))
189
+ : value;
190
+
191
+ // A first waypoint that clicks one of the repository's accounts signs the plan in itself, as some
192
+ // plans that also say `login:` do. Signing in before it would leave that click nothing to find.
193
+ function signsItselfIn(plan, accounts) {
194
+ const names = new Set(accounts.map((account) => account.toLowerCase()));
195
+ return (plan.waypoints[0]?.steps ?? []).some((step) =>
196
+ step.action === "click" && [step.locator?.text, step.locator?.name].some((name) => name !== undefined && names.has(name.toLowerCase())),
197
+ );
198
+ }
199
+
200
+ // The steps that sign a plan in before its first waypoint, or why there are none to run.
201
+ export function signInFor(plan, signIn) {
202
+ const login = plan.login;
203
+ if (!login) return undefined;
204
+ const parsed = (raw) => raw.map(parseStep).filter((step) => step.ignored === undefined);
205
+ if (login.steps) return { steps: parsed(login.steps) };
206
+ if (!signIn) return { steps: [], error: `the plan starts signed in, but the repository has no ${SIGN_IN_FILE}` };
207
+ if (signIn.error) return { steps: [], error: signIn.error };
208
+ if (signsItselfIn(plan, signIn.accounts)) return undefined;
209
+ const account = login.account ?? accountNamed(login.hint, signIn.accounts) ?? signIn.accounts[0] ?? "";
210
+ return { ...(account ? { account } : {}), steps: parsed(fillAccount(signIn.steps, account)) };
211
+ }
212
+
120
213
  function locatorOf(value) {
121
214
  if (typeof value === "string") return { text: value };
122
215
  if (!isObject(value)) return undefined;
@@ -422,14 +515,30 @@ async function runStep(page, step, baseUrl) {
422
515
  }
423
516
  }
424
517
 
425
- async function replay(browser, plan, outDir, prefix) {
518
+ async function replay(browser, plan, outDir, dir) {
426
519
  const context = await browser.newContext({ viewport: plan.viewport, ignoreHTTPSErrors: true });
427
520
  const page = await context.newPage();
428
521
  page.setDefaultTimeout(STEP_TIMEOUT_MS);
429
522
  page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
430
523
 
431
524
  const results = [];
525
+ let signedIn;
432
526
  try {
527
+ if (plan.signIn) {
528
+ // Signing in is not a waypoint: nothing is captured, and a failure is recorded while the
529
+ // journey still runs, so its screenshots show where a missing sign-in left it.
530
+ const errors = plan.signIn.error ? [plan.signIn.error] : [];
531
+ for (const step of plan.signIn.steps) {
532
+ try {
533
+ await runStep(page, step, plan.url);
534
+ } catch (error) {
535
+ errors.push(`${describeStep(step)}: ${firstLine(error)}`);
536
+ break;
537
+ }
538
+ }
539
+ signedIn = { ...(plan.signIn.account ? { account: plan.signIn.account } : {}), reached: errors.length === 0, errors };
540
+ console.log(`${dir} sign-in${plan.signIn.account ? ` as ${plan.signIn.account}` : ""}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
541
+ }
433
542
  for (const waypoint of plan.waypoints) {
434
543
  const errors = [];
435
544
  for (const step of waypoint.steps) {
@@ -442,10 +551,11 @@ async function replay(browser, plan, outDir, prefix) {
442
551
  break;
443
552
  }
444
553
  }
445
- const file = `${prefix}${String(waypoint.n).padStart(2, "0")}-${waypoint.capture}.jpg`;
554
+ // Repository-relative, so the comment, the manifest and the commit all name the same path.
555
+ const file = `${dir}/${EVIDENCE_DIR}/${String(waypoint.n).padStart(2, "0")}-${waypoint.capture}.jpg`;
446
556
  let captured = false;
447
557
  try {
448
- await page.screenshot({ path: join(outDir, file), type: "jpeg", quality: 70, fullPage: true });
558
+ await page.screenshot({ path: join(outDir, "files", file), type: "jpeg", quality: 70, fullPage: true });
449
559
  captured = true;
450
560
  } catch (error) {
451
561
  errors.push(`screenshot: ${firstLine(error)}`);
@@ -459,23 +569,70 @@ async function replay(browser, plan, outDir, prefix) {
459
569
  errors,
460
570
  ignored: waypoint.ignored,
461
571
  });
462
- console.log(`${prefix}${waypoint.n} ${waypoint.capture}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
572
+ console.log(`${dir} ${waypoint.n} ${waypoint.capture}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
463
573
  }
464
574
  } finally {
465
575
  await context.close();
466
576
  }
467
- return results;
577
+ return { waypoints: results, signedIn };
468
578
  }
469
579
 
470
580
  // ── Report ──────────────────────────────────────────────────────────────────────────────────
471
581
 
472
582
  const escapeMarkdown = (text) => String(text).replace(/([\\`*_[\]<>|])/g, "\\$1");
473
583
 
584
+ // The manifest other tools read, as an HTML comment so it never renders. JSON can carry `--` only
585
+ // inside strings, where `\u002d` means the same character, so escaping it keeps the comment closed.
586
+ export function embedManifest(manifest) {
587
+ const compact = (keepExpect) => ({
588
+ version: 2,
589
+ base: IMAGE_BASE,
590
+ sha: manifest.sha ?? "",
591
+ runUrl: manifest.runUrl ?? "",
592
+ ...(manifest.app ? { app: manifest.app } : {}),
593
+ plans: (manifest.plans ?? []).map((plan) => ({
594
+ path: plan.path,
595
+ change: plan.change,
596
+ status: plan.status,
597
+ ...(plan.reason ? { reason: plan.reason } : {}),
598
+ ...(plan.waypoints
599
+ ? {
600
+ waypoints: plan.waypoints.map((w) => ({
601
+ n: w.n,
602
+ capture: w.capture,
603
+ ...(keepExpect && w.expect ? { expect: w.expect } : {}),
604
+ reached: w.reached,
605
+ file: w.file,
606
+ })),
607
+ }
608
+ : {}),
609
+ })),
610
+ });
611
+ let json = JSON.stringify(compact(true));
612
+ if (json.length > EMBED_LIMIT) json = JSON.stringify(compact(false));
613
+ if (json.length > EMBED_LIMIT) return "";
614
+ return `<!-- ${EMBED_MARKER}\n${json.replace(/-(?=-)/g, "\\u002d")}\n-->`;
615
+ }
616
+
617
+ // Reads the embedded manifest back out of a comment body, or null when there is none.
618
+ export function readEmbeddedManifest(body) {
619
+ const match = String(body ?? "").match(new RegExp(`<!-- ${EMBED_MARKER}\\n([\\s\\S]*?)\\n-->`));
620
+ if (!match) return null;
621
+ try {
622
+ return JSON.parse(match[1]);
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+
474
628
  export function renderComment(manifest) {
475
629
  const lines = [MARKER, "## Visual evidence", ""];
476
630
  const short = manifest.sha ? manifest.sha.slice(0, 7) : "";
477
631
  const run = manifest.runUrl ? ` ([run](${manifest.runUrl}))` : "";
478
632
  lines.push(`The verification plan replayed against \`${short}\`, the head of this pull request${run}.`, "");
633
+ if ((manifest.plans ?? []).some((plan) => plan.status === "captured")) {
634
+ lines.push(`The screenshots are kept beside each plan, in its \`${EVIDENCE_DIR}/\` folder on the default branch.`, "");
635
+ }
479
636
 
480
637
  if (manifest.app && manifest.app.started === false) {
481
638
  lines.push(`Nothing was captured: the app did not answer at ${manifest.app.url} within ${manifest.app.waitedSeconds} seconds of \`${manifest.app.start}\`. The run log has the app's output.`, "");
@@ -491,6 +648,10 @@ export function renderComment(manifest) {
491
648
  lines.push(`Not replayed: ${escapeMarkdown(plan.reason || plan.status)}.`, "");
492
649
  continue;
493
650
  }
651
+ if (plan.signIn) {
652
+ const as = plan.signIn.account ? ` as ${escapeMarkdown(plan.signIn.account)}` : "";
653
+ lines.push(plan.signIn.reached ? `Signed in${as} first.` : `> Could not sign in${as} first: ${escapeMarkdown(plan.signIn.errors.join("; "))}`, "");
654
+ }
494
655
  const missed = plan.waypoints.filter((waypoint) => !waypoint.reached).length;
495
656
  const summary = `${plan.waypoints.length} waypoint${plan.waypoints.length === 1 ? "" : "s"}${missed > 0 ? `, ${missed} did not reach their state` : ""}`;
496
657
  lines.push("<details>", `<summary>${summary}</summary>`, "");
@@ -503,6 +664,8 @@ export function renderComment(manifest) {
503
664
  }
504
665
  lines.push("</details>", "");
505
666
  }
667
+ const embedded = embedManifest(manifest);
668
+ if (embedded) lines.push(embedded, "");
506
669
  return `${lines.join("\n").trimEnd()}\n`;
507
670
  }
508
671
 
@@ -535,26 +698,43 @@ async function inspect(root, paths) {
535
698
  };
536
699
  }
537
700
 
701
+ // The repository's sign-in, or null when it has none. The path is relative to the working
702
+ // directory, which is the default branch's checkout, not the pull request head.
703
+ async function loadSignIn(path, parseYaml) {
704
+ let text;
705
+ try {
706
+ text = await readFile(path, "utf8");
707
+ } catch (error) {
708
+ if (error?.code === "ENOENT") return null;
709
+ throw error;
710
+ }
711
+ return parseSignIn(text, parseYaml);
712
+ }
713
+
538
714
  async function capture(root, outDir, paths) {
539
715
  const { parse } = await import("yaml");
540
716
  const { chromium } = await import("playwright-core");
541
717
  await mkdir(outDir, { recursive: true });
542
718
  const plans = await loadPlans(root, paths, parse);
543
719
  const runnable = plans.filter((plan) => plan.status === "runnable");
544
- const manifest = { version: 1, plans: [] };
720
+ const manifest = { version: 2, plans: [] };
721
+ let signIn = null;
722
+ if (runnable.some((plan) => plan.login)) {
723
+ const path = process.env.EVIDENCE_SIGN_IN || SIGN_IN_FILE;
724
+ signIn = await loadSignIn(path, parse).catch((error) => ({ error: `${path}: ${firstLine(error)}` }));
725
+ }
545
726
 
546
727
  const browser = runnable.length > 0 ? await chromium.launch({ headless: true }) : undefined;
547
728
  try {
548
- let index = 0;
549
729
  for (const plan of plans) {
550
730
  if (plan.status !== "runnable") {
551
731
  manifest.plans.push({ path: plan.path, change: plan.change, status: plan.status, reason: plan.reason ?? "" });
552
732
  continue;
553
733
  }
554
- index++;
555
- const prefix = runnable.length > 1 ? `${index}-` : "";
556
- const waypoints = await replay(browser, plan, outDir, prefix);
557
- manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, waypoints });
734
+ const dir = planDir(plan.path);
735
+ await mkdir(join(outDir, "files", dir, EVIDENCE_DIR), { recursive: true });
736
+ const { waypoints, signedIn } = await replay(browser, { ...plan, signIn: signInFor(plan, signIn) }, outDir, dir);
737
+ manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, ...(signedIn ? { signIn: signedIn } : {}), waypoints });
558
738
  }
559
739
  } finally {
560
740
  await browser?.close();
@@ -573,6 +753,12 @@ async function main(argv) {
573
753
  const manifest = await capture(root, outDir, paths);
574
754
  manifest.sha = process.env.EVIDENCE_SHA ?? "";
575
755
  manifest.runUrl = process.env.EVIDENCE_RUN_URL ?? "";
756
+ // Each plan keeps its own record beside its screenshots, so the folder explains itself on the
757
+ // default branch without the pull request comment.
758
+ for (const plan of manifest.plans.filter((p) => p.status === "captured")) {
759
+ const record = { version: 2, sha: manifest.sha, runUrl: manifest.runUrl, plan };
760
+ await writeFile(join(outDir, "files", planDir(plan.path), EVIDENCE_DIR, "manifest.json"), `${JSON.stringify(record, null, 2)}\n`);
761
+ }
576
762
  await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
577
763
  await writeFile(join(outDir, "comment.md"), renderComment(manifest));
578
764
  const shots = manifest.plans.flatMap((plan) => plan.waypoints ?? []).filter((waypoint) => waypoint.file).length;
@@ -586,7 +772,7 @@ async function main(argv) {
586
772
  await mkdir(outDir, { recursive: true });
587
773
  const plans = await loadPlans(root, paths, parse);
588
774
  const manifest = {
589
- version: 1,
775
+ version: 2,
590
776
  sha: process.env.EVIDENCE_SHA ?? "",
591
777
  runUrl: process.env.EVIDENCE_RUN_URL ?? "",
592
778
  app: {
@@ -1723,7 +1723,7 @@ else
1723
1723
  echo "FAIL: evidence.yml's publish job holds a write token and must not check out or run repository code" >&2
1724
1724
  fi
1725
1725
  ev_absent 'secrets\.' "$EVIDENCE_YML" 'evidence.yml must need no secret; GITHUB_TOKEN is enough'
1726
- ev_absent 'gh (issue|pr) comment.*--attach' "$EVIDENCE_YML" 'gh --attach refuses installation tokens; publish through the evidence branch'
1726
+ ev_absent 'gh (issue|pr) comment.*--attach' "$EVIDENCE_YML" 'gh --attach refuses installation tokens; commit the screenshots beside their plans'
1727
1727
  fi
1728
1728
 
1729
1729
  # The retired worker stays retired: nothing dispatches it and nothing waits for it.
@@ -626,7 +626,10 @@ earlier versions of them passed against a deliberately broken guard and were rew
626
626
 
627
627
  Archives what a merged pull request looks like. Not an agent: the plan was written by the agent that
628
628
  implemented the change (`pc-repo-verify`), and this replays it. The router calls it when a pull
629
- request merges, and `operation=evidence` runs it by hand. Nothing waits on it and nothing reads it.
629
+ request merges, and `operation=evidence` runs it by hand. Nothing waits on it. The screenshots are
630
+ committed beside each plan, in an `evidence/` folder on the default branch, so the record stays with
631
+ the change it proves; the comment embeds a manifest that Project Health reads. A pull request that
632
+ is not merged has no plan on the default branch, so its screenshots stay in the run's artifact.
630
633
  The capture job runs the pull request's code with a read-only token; only the publish job writes,
631
634
  and it runs no repository code.
632
635
 
@@ -645,8 +648,8 @@ flowchart TD
645
648
  evUp -.->|"no, after the timeout"| evUnreached
646
649
  evReplay["replay each waypoint<br/>in headless Chromium,<br/>one JPEG per waypoint"] --> evPublish
647
650
  evUnreached["record why nothing<br/>was captured"] --> evPublish
648
- evPublish["publish (GITHUB_TOKEN)<br/>commit to the orphan evidence branch,<br/>upsert one comment"] --> evDone
649
- evDone(("Archived<br/>on the pull request"))
651
+ evPublish["publish (GITHUB_TOKEN)<br/>commit beside each plan,<br/>evidence/ on the default branch,<br/>upsert one comment"] --> evDone
652
+ evDone(("Archived<br/>beside the plan"))
650
653
  evIdle(("Nothing to capture"))
651
654
 
652
655
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -3,15 +3,17 @@ name: "Agentics: Evidence"
3
3
 
4
4
  # Archives what a merged pull request looks like. The agent that implemented the change wrote a
5
5
  # verification plan with the harness skill pc-repo-verify; this replays that plan against the
6
- # app, with no model, and keeps one screenshot per waypoint on the pull request. The router calls
7
- # it when a pull request merges, and operation=evidence runs it by hand for any pull request.
8
- # Nothing waits on it and nothing reads it: it is the record, not a gate.
6
+ # app, with no model, and keeps one screenshot per waypoint beside the plan it came from. The
7
+ # router calls it when a pull request merges, and operation=evidence runs it by hand for any pull
8
+ # request. Nothing waits on it: it is the record, not a gate. Project Health reads the manifest
9
+ # embedded in the comment to show the screenshots with each issue.
9
10
  #
10
11
  # Two jobs, because one runs the pull request's code and the other writes. `capture` checks out
11
12
  # the head, starts the app and drives Chromium with a read-only token and no secret. `publish`
12
- # checks nothing out: it takes the images as an artifact, commits them to the orphan `evidence`
13
- # branch and writes one comment. Both use GITHUB_TOKEN, so a repository configures nothing, and
14
- # nothing either job writes raises another workflow event.
13
+ # checks nothing out: it takes the images as an artifact, commits them into an `evidence/` folder
14
+ # beside each plan on the default branch and writes one comment. Both use GITHUB_TOKEN, and nothing
15
+ # either job writes raises another workflow event. The one thing a repository may add is its own
16
+ # .github/evidence/sign-in.yml, for plans that start signed in rather than signing in themselves.
15
17
  #
16
18
  # This replaced agent-visual-verify, which never produced a screenshot: it ran only on pull
17
19
  # requests nobody would review, raced the merge it was dispatched before, re-planned with a model
@@ -35,8 +37,10 @@ env:
35
37
  EVIDENCE_START_TIMEOUT_SECONDS: "900"
36
38
  # The SDK when a repository has .NET projects but no global.json to say which.
37
39
  EVIDENCE_DOTNET_VERSION: "10.0.x"
38
- EVIDENCE_BRANCH: evidence
39
40
  EVIDENCE_MARKER: "<!-- agentic-evidence -->"
41
+ # How long the run keeps the screenshots it could not archive: a pull request that is not merged
42
+ # has no plan on the default branch to keep them beside.
43
+ EVIDENCE_ARTIFACT_DAYS: "14"
40
44
  # The capture runs in mcr.microsoft.com/playwright at this tag, pre-pulled by the runner image
41
45
  # (runners/versions.env), so the two move together.
42
46
  PLAYWRIGHT_VERSION: "1.62.1"
@@ -66,8 +70,11 @@ jobs:
66
70
  const pull_number = Number(process.env.PR_NUMBER);
67
71
  const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number });
68
72
  const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number, per_page: 100 });
73
+ // A plan that was only moved (the archive step, in a later pull request) is skipped: its
74
+ // evidence/ folder moves with it, so replaying it would only repeat the record.
69
75
  const plans = files
70
76
  .filter((file) => file.status !== 'removed' && /(^|\/)verification-plan\.md$/.test(file.filename))
77
+ .filter((file) => !(file.status === 'renamed' && file.changes === 0))
71
78
  .map((file) => file.filename);
72
79
  core.setOutput('sha', pr.head.sha);
73
80
  core.setOutput('plans', plans.join('\n'));
@@ -185,6 +192,25 @@ jobs:
185
192
  echo "::group::Last lines of the app's output"
186
193
  tail -n 80 "$log" || true
187
194
  echo "::endgroup::"
195
+ # An orchestrator (Aspire, compose) keeps each service's output to itself, so the
196
+ # start command's own log rarely says which service never came up. Show them.
197
+ echo "::group::Listening ports"
198
+ (ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null) | head -n 40 || true
199
+ echo "::endgroup::"
200
+ echo "::group::Containers"
201
+ docker ps -a --format '{{.Names}} {{.Status}} {{.Image}}' 2>/dev/null || true
202
+ echo "::endgroup::"
203
+ for id in $(docker ps -aq 2>/dev/null); do
204
+ echo "::group::Container $(docker inspect -f '{{.Name}}' "$id" 2>/dev/null)"
205
+ docker logs --tail 60 "$id" 2>&1 || true
206
+ echo "::endgroup::"
207
+ done
208
+ for f in "$HOME"/.aspire/logs/*.log; do
209
+ [ -f "$f" ] || continue
210
+ echo "::group::$f"
211
+ tail -n 200 "$f" || true
212
+ echo "::endgroup::"
213
+ done
188
214
  fi
189
215
 
190
216
  - name: Replay the plans
@@ -240,7 +266,7 @@ jobs:
240
266
  with:
241
267
  name: evidence-pr-${{ inputs.pr-number }}
242
268
  path: ${{ runner.temp }}/evidence
243
- retention-days: 3
269
+ retention-days: ${{ env.EVIDENCE_ARTIFACT_DAYS }}
244
270
  if-no-files-found: error
245
271
 
246
272
  publish:
@@ -259,72 +285,113 @@ jobs:
259
285
  name: evidence-pr-${{ inputs.pr-number }}
260
286
  path: ${{ runner.temp }}/evidence
261
287
 
262
- # An orphan branch rather than the default branch: the images stay out of every build, every
263
- # diff and every review, and a clone that only wants the code can leave the branch behind.
264
- # One folder per pull request, so two publishes never touch the same file and a lost push
265
- # race is a retry, never a conflict.
266
- - name: Commit the screenshots to the evidence branch
267
- id: branch
288
+ - name: Read the pull request
289
+ id: pr
290
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
291
+ env:
292
+ PR_NUMBER: ${{ inputs.pr-number }}
293
+ with:
294
+ script: |
295
+ const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.PR_NUMBER) });
296
+ core.setOutput('merged', pr.merged ? 'true' : 'false');
297
+ core.setOutput('branch', pr.base.ref);
298
+
299
+ # Beside each plan on the default branch, where the plan itself is archived: the record stays
300
+ # with the change it proves, and moves with it if the change folder ever moves. Only a merged
301
+ # pull request's plans are there to keep it beside. A sparse, blob-less fetch reads only the
302
+ # plan folders, and [skip ci] plus GITHUB_TOKEN keep the commit from starting any workflow.
303
+ - name: Commit the screenshots beside their plans
304
+ id: archive
305
+ if: steps.pr.outputs.merged == 'true'
268
306
  env:
269
307
  TOKEN: ${{ github.token }}
270
308
  REPO: ${{ github.repository }}
271
309
  SERVER: ${{ github.server_url }}
272
- BRANCH: ${{ env.EVIDENCE_BRANCH }}
310
+ BRANCH: ${{ steps.pr.outputs.branch }}
273
311
  PR: ${{ inputs.pr-number }}
274
312
  run: |
275
313
  set -euo pipefail
276
- src="$RUNNER_TEMP/evidence"
277
- if ! compgen -G "$src/*.jpg" > /dev/null; then
314
+ src="$RUNNER_TEMP/evidence/files"
315
+ if [ ! -d "$src" ] || [ -z "$(find "$src" -name '*.jpg' -print -quit)" ]; then
278
316
  echo "No screenshots to keep; the comment records why."
279
317
  echo "base=" >> "$GITHUB_OUTPUT"
280
318
  exit 0
281
319
  fi
282
320
 
283
- work="$RUNNER_TEMP/evidence-branch"
321
+ # Each folder holding screenshots is <plan directory>/evidence.
322
+ mapfile -t dirs < <(cd "$src" && find . -type d -name evidence | sed 's|^\./||; s|/evidence$||' | sort)
323
+
324
+ work="$RUNNER_TEMP/evidence-commit"
284
325
  rm -rf "$work"
285
- mkdir -p "$work"
326
+ auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
327
+ # Blob-less and sparse: only the plan folders are downloaded, however large the repository.
328
+ git -c "http.${SERVER}/.extraheader=${auth}" clone -q --filter=blob:none --no-checkout --depth=1 \
329
+ --branch "$BRANCH" "${SERVER}/${REPO}.git" "$work"
286
330
  cd "$work"
287
- git init -q
331
+ git config "http.${SERVER}/.extraheader" "$auth"
288
332
  git config user.name "github-actions[bot]"
289
333
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
290
- git config "http.${SERVER}/.extraheader" "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
291
- git remote add origin "${SERVER}/${REPO}.git"
334
+ patterns=()
335
+ for dir in "${dirs[@]}"; do patterns+=("/$dir/"); done
336
+ git sparse-checkout set --no-cone "${patterns[@]}"
292
337
 
293
338
  pushed=false
339
+ missing=()
294
340
  for attempt in 1 2 3 4 5; do
295
- if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
296
- git fetch -q --depth=1 origin "$BRANCH"
297
- git checkout -q -f -B "$BRANCH" FETCH_HEAD
341
+ if [ "$attempt" -eq 1 ]; then
342
+ git checkout -q "$BRANCH"
298
343
  else
299
- git checkout -q -f --orphan "$BRANCH"
300
- git rm -rqf --cached . > /dev/null 2>&1 || true
301
- printf '%s\n' "# Evidence" "" \
302
- "Screenshots the evidence workflow replayed from each merged pull request's verification plan, one folder per pull request." \
303
- "Written by a workflow, never by hand. Nothing here is built, deployed or merged." > README.md
304
- git add README.md
344
+ git fetch -q --depth=1 origin "$BRANCH"
345
+ git reset -q --hard FETCH_HEAD
305
346
  fi
306
-
307
- rm -rf "pr-$PR"
308
- mkdir -p "pr-$PR"
309
- cp "$src"/*.jpg "$src/manifest.json" "pr-$PR/"
310
- git add -A "pr-$PR"
347
+ missing=()
348
+ for dir in "${dirs[@]}"; do
349
+ # A plan moved or removed since the merge has no folder to keep the record in.
350
+ if [ ! -f "$dir/verification-plan.md" ]; then
351
+ missing+=("$dir")
352
+ continue
353
+ fi
354
+ rm -rf "$dir/evidence"
355
+ mkdir -p "$dir/evidence"
356
+ cp -R "$src/$dir/evidence/." "$dir/evidence/"
357
+ git add -A "$dir/evidence"
358
+ done
311
359
  if git diff --cached --quiet; then
312
360
  pushed=true
313
361
  break
314
362
  fi
315
- git commit -q -m "evidence: pull request #$PR"
363
+ git commit -q -m "evidence: screenshots for #$PR [skip ci]"
316
364
  if git push -q origin "HEAD:refs/heads/$BRANCH"; then
317
365
  pushed=true
318
366
  break
319
367
  fi
320
- echo "Another publish moved $BRANCH first (attempt $attempt); retrying on its new tip."
368
+ echo "$BRANCH moved before the push (attempt $attempt); retrying on its new tip."
321
369
  sleep $((attempt * 3))
322
370
  done
323
- [ "$pushed" = "true" ] || { echo "::error::could not push to $BRANCH after 5 attempts"; exit 1; }
371
+ if [ "$pushed" != "true" ]; then
372
+ # A protected default branch refuses the push. The comment says so, and the run's
373
+ # artifact keeps the screenshots, so the merge that already happened stays green.
374
+ echo "::warning::could not push the screenshots to $BRANCH after 5 attempts"
375
+ echo "base=" >> "$GITHUB_OUTPUT"
376
+ echo "reason=the push to $BRANCH was refused" >> "$GITHUB_OUTPUT"
377
+ exit 0
378
+ fi
379
+
380
+ if [ "${#missing[@]}" -eq "${#dirs[@]}" ]; then
381
+ echo "None of the plans is on $BRANCH any more; nothing was kept."
382
+ echo "base=" >> "$GITHUB_OUTPUT"
383
+ echo "reason=none of the plans is on $BRANCH any more" >> "$GITHUB_OUTPUT"
384
+ exit 0
385
+ fi
324
386
 
325
387
  sha="$(git rev-parse HEAD)"
326
- echo "base=${SERVER}/${REPO}/raw/${sha}/pr-${PR}" >> "$GITHUB_OUTPUT"
327
- echo "Screenshots kept at ${SERVER}/${REPO}/tree/${sha}/pr-${PR}"
388
+ echo "base=${SERVER}/${REPO}/raw/${sha}" >> "$GITHUB_OUTPUT"
389
+ {
390
+ echo "missing<<EOF"
391
+ printf '%s\n' "${missing[@]}"
392
+ echo "EOF"
393
+ } >> "$GITHUB_OUTPUT"
394
+ echo "Screenshots kept at ${SERVER}/${REPO}/tree/${sha} beside $(( ${#dirs[@]} - ${#missing[@]} )) plan(s)"
328
395
 
329
396
  # GITHUB_TOKEN on purpose: its comment raises no workflow event, so the router does not wake
330
397
  # up to read its own record. One comment per pull request, rewritten on a re-run.
@@ -333,14 +400,56 @@ jobs:
333
400
  env:
334
401
  PR_NUMBER: ${{ inputs.pr-number }}
335
402
  MARKER: ${{ env.EVIDENCE_MARKER }}
336
- IMAGE_BASE: ${{ steps.branch.outputs.base }}
403
+ MERGED: ${{ steps.pr.outputs.merged }}
404
+ IMAGE_BASE: ${{ steps.archive.outputs.base }}
405
+ MISSING: ${{ steps.archive.outputs.missing }}
406
+ REASON: ${{ steps.archive.outputs.reason }}
407
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
408
+ ARTIFACT_DAYS: ${{ env.EVIDENCE_ARTIFACT_DAYS }}
337
409
  with:
338
410
  script: |
339
411
  const fs = require('node:fs');
340
412
  const path = require('node:path');
341
413
  const issue_number = Number(process.env.PR_NUMBER);
342
- const source = fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'evidence', 'comment.md'), 'utf8');
343
- const body = source.split('__EVIDENCE_BASE__').join(process.env.IMAGE_BASE);
414
+ const evidence = path.join(process.env.RUNNER_TEMP, 'evidence');
415
+ let source = fs.readFileSync(path.join(evidence, 'comment.md'), 'utf8');
416
+ const base = process.env.IMAGE_BASE ?? '';
417
+ const missing = (process.env.MISSING ?? '').split('\n').map((line) => line.trim()).filter(Boolean);
418
+ const kept = (file) => Boolean(base) && !missing.some((dir) => file.startsWith(`${dir}/evidence/`));
419
+
420
+ // An image that was not archived would render broken: its line goes, and a note says
421
+ // the run's artifact has it instead.
422
+ source = source.replace(/^!\[[^\]]*\]\(__EVIDENCE_BASE__\/([^)]*)\)\n?/gm, (line, file) => (kept(file) ? line : ''));
423
+ const shots = fs.readdirSync(evidence, { recursive: true }).some((name) => String(name).endsWith('.jpg'));
424
+ if (!base) source = source.replace(/^The screenshots are kept beside each plan.*\n\n?/m, '');
425
+ if (shots && (!base || missing.length > 0)) {
426
+ const why = process.env.MERGED !== 'true'
427
+ ? 'the pull request is not merged, so its plans are not on the default branch'
428
+ : !base
429
+ ? (process.env.REASON || 'the screenshots could not be committed')
430
+ : `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} no longer on the default branch`;
431
+ source = source.replace('## Visual evidence\n', `## Visual evidence\n\n> Not archived: ${why}. The screenshots are in [the run](${process.env.RUN_URL})'s artifact for ${process.env.ARTIFACT_DAYS} days.\n`);
432
+ }
433
+
434
+ // The embedded manifest is what other tools read, so it says exactly what was kept: the
435
+ // base where the files are, and no file for a screenshot that was not archived. It sits
436
+ // in an HTML comment, which a `--` would close; `\u002d` is the same to a JSON reader.
437
+ source = source.replace(/<!-- agentic-evidence-manifest\n([\s\S]*?)\n-->/, (block, json) => {
438
+ let data;
439
+ try {
440
+ data = JSON.parse(json);
441
+ } catch {
442
+ return block;
443
+ }
444
+ data.base = base || null;
445
+ for (const plan of data.plans ?? []) {
446
+ for (const waypoint of plan.waypoints ?? []) {
447
+ if (waypoint.file && !kept(waypoint.file)) waypoint.file = null;
448
+ }
449
+ }
450
+ return `<!-- agentic-evidence-manifest\n${JSON.stringify(data).replace(/-(?=-)/g, '\\u002d')}\n-->`;
451
+ });
452
+ const body = source.split('__EVIDENCE_BASE__').join(base);
344
453
  const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number, per_page: 100 });
345
454
  const existing = comments.find((comment) => comment.user?.type === 'Bot' && (comment.body ?? '').includes(process.env.MARKER));
346
455
  if (existing) {
@@ -630,8 +630,8 @@ jobs:
630
630
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
631
631
 
632
632
  # Screenshots of a merged pull request, replayed from the verification plan its implementation
633
- # wrote and archived on the pull request. Plain YAML with no model, so no secret is passed and the
634
- # grant is exactly what the publish job writes: the evidence branch and one comment. Its capture
633
+ # wrote and archived beside that plan. Plain YAML with no model, so no secret is passed and the
634
+ # grant is exactly what the publish job writes: one commit of evidence/ folders and one comment. Its capture
635
635
  # job runs the pull request's code with read-only access; only publish holds these writes, and it
636
636
  # runs no repository code.
637
637
  call-evidence:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",