@patronage/factory-ci 0.1.2 → 0.2.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 +40 -4
- package/dist/index.d.ts +222 -1
- package/dist/index.js +515 -1
- package/package.json +2 -2
- package/src/index.ts +26 -3
- package/src/proof-reuse-gate.ts +669 -0
package/dist/index.js
CHANGED
|
@@ -241,4 +241,518 @@ const executeAlchemyEntry = async (options) => {
|
|
|
241
241
|
};
|
|
242
242
|
};
|
|
243
243
|
//#endregion
|
|
244
|
-
|
|
244
|
+
//#region src/proof-reuse-gate.ts
|
|
245
|
+
/**
|
|
246
|
+
* The GitHub-side **proof-reuse gate** (ADR 0022, #309, #317).
|
|
247
|
+
*
|
|
248
|
+
* It answers one question, before checkout and before install: may this job
|
|
249
|
+
* reuse the local verification the factory already published for *this exact
|
|
250
|
+
* head*? Refusing runs hosted CI; it never fails the candidate. Three
|
|
251
|
+
* repositories had independently grown their own answer to that question and
|
|
252
|
+
* had already drifted apart, so the answer lives here once, with the App
|
|
253
|
+
* identity, check name, step identity, output names, guard, and every trust
|
|
254
|
+
* predicate fixed rather than consumer-configurable.
|
|
255
|
+
*
|
|
256
|
+
* What a consumer chooses is only *what its guarded surface requires*: the
|
|
257
|
+
* plain profile command objects that surface selects. Everything else below is
|
|
258
|
+
* the same for every consumer and every surface.
|
|
259
|
+
*/
|
|
260
|
+
/**
|
|
261
|
+
* Escaped so the emitted text carries a GitHub Actions expression rather than
|
|
262
|
+
* this file carrying a JavaScript template hole.
|
|
263
|
+
*/
|
|
264
|
+
const githubExpression = (expression) => `\${{ ${expression} }}`;
|
|
265
|
+
/** A braced shell expansion that has to survive TypeScript interpolation. */
|
|
266
|
+
const shellExpansion = (expression) => `\${${expression}}`;
|
|
267
|
+
/**
|
|
268
|
+
* Single-quote a value for the emitted script. Every non-literal value that
|
|
269
|
+
* reaches the script goes through this.
|
|
270
|
+
*
|
|
271
|
+
* `JSON.stringify` is *not* a shell quoter — it escapes `"` and `\` and leaves
|
|
272
|
+
* `'` untouched — so interpolating a JSON document straight into a
|
|
273
|
+
* single-quoted assignment lets one apostrophe in a profile command name close
|
|
274
|
+
* the assignment and run the remainder as shell. Names are charset-validated
|
|
275
|
+
* before they get here; this is the second layer, so the interpolation stays
|
|
276
|
+
* safe if that validation is ever loosened.
|
|
277
|
+
*/
|
|
278
|
+
const shellSingleQuote = (value) => `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
279
|
+
/**
|
|
280
|
+
* The check run the gate reads. Fixed: `patronage-factory/pr-verify` is also
|
|
281
|
+
* postable as a *commit status* by any token with write access, so only the
|
|
282
|
+
* Checks API is ever consulted and only under this exact name.
|
|
283
|
+
*/
|
|
284
|
+
const FACTORY_PROOF_GATE_CHECK_NAME = "patronage-factory/pr-verify";
|
|
285
|
+
/**
|
|
286
|
+
* The Patronage Factory GitHub App, pinned (ADR 0022). A runner has no
|
|
287
|
+
* operator user config to read the producing App identity from, and the
|
|
288
|
+
* identity is a trust predicate, so it is not a repository variable a consumer
|
|
289
|
+
* can point somewhere else. Neither the id nor the slug is a secret: GitHub
|
|
290
|
+
* returns `app.id` on every check run it serves.
|
|
291
|
+
*/
|
|
292
|
+
const FACTORY_PROOF_GATE_APP_ID = "4314840";
|
|
293
|
+
/** Step id the guard condition refers to. */
|
|
294
|
+
const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
|
|
295
|
+
/**
|
|
296
|
+
* The shell the gate runs under, and it is a correctness requirement rather
|
|
297
|
+
* than a preference.
|
|
298
|
+
*
|
|
299
|
+
* GitHub's default for a `run:` step is `bash -e {0}`: **errexit comes from
|
|
300
|
+
* the invocation, not from the script**, and no `set +e` in the script body
|
|
301
|
+
* can be relied on to express the intent because the abort happens at the
|
|
302
|
+
* failing command. The whole fail-open design below — every path reaching one
|
|
303
|
+
* final `GITHUB_OUTPUT` write, no `set -e` — is void under that default. It
|
|
304
|
+
* was: the gate died at a `read` returning 1 at EOF, two seconds in, with no
|
|
305
|
+
* stdout, no verdict and no `reason`, so reuse collapsed to never *and* the
|
|
306
|
+
* signal built to reveal that could not fire (#319).
|
|
307
|
+
*
|
|
308
|
+
* A `shell:` value containing `{0}` is used verbatim by the runner, so this
|
|
309
|
+
* form supplies no `-e`. The shorthand `shell: bash` must never be used here:
|
|
310
|
+
* it expands to `bash --noprofile --norc -eo pipefail {0}`. `pipefail` is set
|
|
311
|
+
* by the script itself.
|
|
312
|
+
*
|
|
313
|
+
* The script is additionally written to survive errexit anyway. Both layers
|
|
314
|
+
* are deliberate: this one states the contract, that one means a runner which
|
|
315
|
+
* ignores it still gets a decision.
|
|
316
|
+
*/
|
|
317
|
+
const FACTORY_PROOF_GATE_SHELL = "bash --noprofile --norc {0}";
|
|
318
|
+
/** Output the verdict is written to. `true` — and only `true` — skips. */
|
|
319
|
+
const FACTORY_PROOF_GATE_OUTPUT = "reuse-proof";
|
|
320
|
+
/**
|
|
321
|
+
* Machine-readable refusal category. Fleet-status reads this: it is what makes
|
|
322
|
+
* a reuse-collapses-to-never regression visible instead of hidden behind
|
|
323
|
+
* fail-open, which by construction looks exactly like healthy full CI.
|
|
324
|
+
*/
|
|
325
|
+
const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
|
|
326
|
+
/**
|
|
327
|
+
* Recorded verification mode, when the gate could read one. Diagnostic
|
|
328
|
+
* metadata only — never an authorization condition (ADR 0022). A reduced-mode
|
|
329
|
+
* proof that executed every command the surface requires is reusable.
|
|
330
|
+
*/
|
|
331
|
+
const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
|
|
332
|
+
/**
|
|
333
|
+
* Guard for every step the gate protects. Deliberately `!= 'true'` and not
|
|
334
|
+
* `== 'false'`: an unset, empty, or garbled output must run the suite.
|
|
335
|
+
*/
|
|
336
|
+
const FACTORY_PROOF_GATE_GUARD = `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT} != 'true'`;
|
|
337
|
+
/**
|
|
338
|
+
* Pull requests only. A push to a protected branch must never skip: deploy
|
|
339
|
+
* gates wait for a green required check at the *merged* SHA, and no local
|
|
340
|
+
* proof was ever written for that tree.
|
|
341
|
+
*/
|
|
342
|
+
const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
|
|
343
|
+
/**
|
|
344
|
+
* The complete refusal vocabulary. Deliberately few, because these are the
|
|
345
|
+
* only distinctions the gate can honestly make from one Checks API read.
|
|
346
|
+
*
|
|
347
|
+
* - `proven` a covering, passing, unambiguous proof for this exact head
|
|
348
|
+
* - `none` no App-verified factory check run at this commit
|
|
349
|
+
* - `pending` the newest generation had not completed when this job read it
|
|
350
|
+
* - `failed` the newest generation records no pass
|
|
351
|
+
* - `unreadable` it passed but carries no binding for this repository and head
|
|
352
|
+
* - `incomplete` it passed but did not execute every required command
|
|
353
|
+
* - `ambiguous` two newest generations share the greatest start time
|
|
354
|
+
* - `error` the gate could not reach a decision (fail open)
|
|
355
|
+
*
|
|
356
|
+
* There is no `reduced`: mode stopped being an authorization condition in
|
|
357
|
+
* ADR 0022, so a narrower run that still executed every required command is
|
|
358
|
+
* indistinguishable from a full one *for this surface*, which is the point.
|
|
359
|
+
*/
|
|
360
|
+
const FACTORY_PROOF_GATE_REASONS = [
|
|
361
|
+
"proven",
|
|
362
|
+
"none",
|
|
363
|
+
"pending",
|
|
364
|
+
"failed",
|
|
365
|
+
"unreadable",
|
|
366
|
+
"incomplete",
|
|
367
|
+
"ambiguous",
|
|
368
|
+
"error"
|
|
369
|
+
];
|
|
370
|
+
/**
|
|
371
|
+
* Command identities are baked into the emitted script, so their charset is a
|
|
372
|
+
* safety property, not a style rule. `surface` has always gone through
|
|
373
|
+
* `safeLabel` for this reason; `name` needs the same treatment.
|
|
374
|
+
*
|
|
375
|
+
* Deliberately a strict allow-list rather than a quote-escaper: a name is an
|
|
376
|
+
* identifier, and every identity across the fleet's profiles today is
|
|
377
|
+
* `[a-z0-9:-]`. Anything outside this — hostile or merely a stray apostrophe —
|
|
378
|
+
* makes the whole selection unusable, which degrades to the refusing script.
|
|
379
|
+
* A stray apostrophe reaching interpolation would otherwise kill the step with
|
|
380
|
+
* a bash syntax error *before* the single `GITHUB_OUTPUT` write, so reuse
|
|
381
|
+
* would collapse to never with no `reason` written at all — the silent
|
|
382
|
+
* regression the vocabulary exists to make visible.
|
|
383
|
+
*/
|
|
384
|
+
const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
|
|
385
|
+
const COMMAND_IDENTITY_MAX_LENGTH = 120;
|
|
386
|
+
const isProofReuseCommand = (value) => {
|
|
387
|
+
if (!(value && typeof value === "object")) return false;
|
|
388
|
+
const entry = value;
|
|
389
|
+
return typeof entry.command === "string" && entry.command.length > 0 && typeof entry.name === "string" && entry.name.length <= COMMAND_IDENTITY_MAX_LENGTH && COMMAND_IDENTITY.test(entry.name);
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* The command identities a guarded surface requires, derived from the profile
|
|
393
|
+
* command objects that surface selected. This is the single derivation: the
|
|
394
|
+
* gate script and the static coverage assertion both go through it, so a
|
|
395
|
+
* consumer can never have one notion of "required" at generation time and
|
|
396
|
+
* another at assertion time.
|
|
397
|
+
*
|
|
398
|
+
* `undefined` means the selection is unusable — not an array, empty, or
|
|
399
|
+
* carrying an entry whose `command` is blank or whose `name` is not a plain
|
|
400
|
+
* command identity. An empty required set would make *every* passing proof
|
|
401
|
+
* trivially covering, so it is never silently treated as "requires nothing";
|
|
402
|
+
* callers must refuse instead.
|
|
403
|
+
*/
|
|
404
|
+
const proofReuseRequiredCommands = (commands) => {
|
|
405
|
+
if (!(Array.isArray(commands) && commands.length > 0)) return;
|
|
406
|
+
if (!commands.every(isProofReuseCommand)) return;
|
|
407
|
+
return [...new Set(commands.map(({ name }) => name))].toSorted();
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* jq program: every page of the Checks API result in, three sanitized lines
|
|
411
|
+
* (`reason`, `mode`, missing commands) out.
|
|
412
|
+
*
|
|
413
|
+
* The input is what `gh api --paginate` actually writes: the pages
|
|
414
|
+
* *concatenated* as a stream of top-level response objects, not merged into
|
|
415
|
+
* one document and not wrapped in an array. That is why the script invokes
|
|
416
|
+
* `jq -s` and why this program opens with `.[] | (.check_runs // [])[]`. A
|
|
417
|
+
* test double that emits `[{ "check_runs": [...] }]` instead is a different
|
|
418
|
+
* shape: `jq` fails with `Cannot index array with string ("check_runs")`, the
|
|
419
|
+
* script lands on `reason=error`, and — because the gate fails open — the
|
|
420
|
+
* result looks exactly like healthy full CI. Stub `gh` with a bare response
|
|
421
|
+
* object per page.
|
|
422
|
+
*
|
|
423
|
+
* `binding` recovers the machine-readable payload from `output.text`
|
|
424
|
+
* (preferred) or `output.summary`, which the factory writes as a fenced JSON
|
|
425
|
+
* document. Anything unparseable degrades to `null`, and therefore to a
|
|
426
|
+
* refusal.
|
|
427
|
+
*
|
|
428
|
+
* This is the *second* parser of that payload: `parsePrVerifyCheckPayload`
|
|
429
|
+
* (`software-factory/src/pr-verify-check-payload.ts`) regex-extracts the fenced
|
|
430
|
+
* block, while this one strips fence lines and `fromjson`s what remains. The
|
|
431
|
+
* one input that separates them is prose around the fence, which parses there
|
|
432
|
+
* and refuses here. Both halves of that are asserted in the test suite rather
|
|
433
|
+
* than asserted here in prose — the exact shape the renderer emits is proven,
|
|
434
|
+
* and the divergent shape is proven to fail safe. Two parsers of one payload
|
|
435
|
+
* can drift; what must not drift silently is which direction they drift in.
|
|
436
|
+
*
|
|
437
|
+
* Ordering is by `started_at` alone, normalized to whole seconds. Two
|
|
438
|
+
* generations that share the greatest start time are `ambiguous` rather than
|
|
439
|
+
* arbitrarily resolved: the gate cannot tell which one describes the tree, and
|
|
440
|
+
* silently picking either is exactly the kind of coin-flip authorization this
|
|
441
|
+
* whole mechanism exists to remove. Sub-second precision is dropped so
|
|
442
|
+
* `…:00Z` and `…:00.000Z` compare equal; two genuine verification generations
|
|
443
|
+
* starting within the same second at the same head is not a real state, and
|
|
444
|
+
* collapsing them errs toward refusing.
|
|
445
|
+
*
|
|
446
|
+
* The comparison is lexical, which is correct only for fixed-width UTC. A
|
|
447
|
+
* `+HH:MM` offset would sort by its literal text rather than by its instant,
|
|
448
|
+
* and an absent `started_at` would sort to the bottom. The Checks API returns
|
|
449
|
+
* neither today, so this is guarded rather than merely noted: every candidate
|
|
450
|
+
* must carry a canonical UTC timestamp, and one that does not makes the newest
|
|
451
|
+
* generation unestablishable, which is `ambiguous`. Refusing costs a hosted
|
|
452
|
+
* run; ranking an unorderable set could authorize the wrong one.
|
|
453
|
+
*/
|
|
454
|
+
const GATE_JQ = String.raw`
|
|
455
|
+
def binding:
|
|
456
|
+
[ .output.text, .output.summary ]
|
|
457
|
+
| map(select(type == "string"))
|
|
458
|
+
| map(split("\n") | map(select(startswith("${"```"}") | not)) | join("\n"))
|
|
459
|
+
| map(. as $body | try ($body | fromjson) catch null)
|
|
460
|
+
| map(select(type == "object"))
|
|
461
|
+
| .[0];
|
|
462
|
+
|
|
463
|
+
def startedAt: (.started_at // "") | tostring;
|
|
464
|
+
|
|
465
|
+
def rankable:
|
|
466
|
+
test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
|
|
467
|
+
|
|
468
|
+
[ .[]
|
|
469
|
+
| (.check_runs // [])[]
|
|
470
|
+
| select(.name == $name)
|
|
471
|
+
| select(((.app.id // "") | tostring) == $app)
|
|
472
|
+
| { started: startedAt,
|
|
473
|
+
status: (.status // ""),
|
|
474
|
+
conclusion: (.conclusion // ""),
|
|
475
|
+
binding: binding }
|
|
476
|
+
] as $runs
|
|
477
|
+
| ($runs | map(.started | rankable) | all) as $orderable
|
|
478
|
+
| ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
|
|
479
|
+
| [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
|
|
480
|
+
| (if ($runs | length) == 0 then ["none", "", ""]
|
|
481
|
+
elif ($orderable | not) then ["ambiguous", "", ""]
|
|
482
|
+
elif ($generation | length) != 1 then ["ambiguous", "", ""]
|
|
483
|
+
else
|
|
484
|
+
$generation[0] as $run
|
|
485
|
+
| (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
|
|
486
|
+
| (($proof.mode // "") | tostring) as $mode
|
|
487
|
+
| (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
|
|
488
|
+
| ($required - $executed) as $missing
|
|
489
|
+
| (if $run.status != "completed" then "pending"
|
|
490
|
+
elif $run.conclusion != "success" then "failed"
|
|
491
|
+
elif (($run.binding | type) != "object")
|
|
492
|
+
or ($proof.kind != "pr-verify-proof-binding")
|
|
493
|
+
or ($proof.schemaVersion != 1)
|
|
494
|
+
or ($proof.headSha != $sha)
|
|
495
|
+
or ($proof.repository != $repository) then "unreadable"
|
|
496
|
+
elif $proof.outcome != "passed" then "failed"
|
|
497
|
+
elif ($required | length) == 0 then "incomplete"
|
|
498
|
+
elif ($missing | length) != 0 then "incomplete"
|
|
499
|
+
else "proven"
|
|
500
|
+
end) as $reason
|
|
501
|
+
| [$reason, $mode, ($missing | join(", "))]
|
|
502
|
+
end)
|
|
503
|
+
| map(gsub("[\\r\\n\\t]"; " "))
|
|
504
|
+
| join("\n")
|
|
505
|
+
`.trim();
|
|
506
|
+
/**
|
|
507
|
+
* Every refusal ends on the same line. `pr:verify` is the command that
|
|
508
|
+
* produces the proof this job looks for, so it is the corrective action
|
|
509
|
+
* whatever the refusal was.
|
|
510
|
+
*
|
|
511
|
+
* It is phrased as an option, not a reprimand. A pull request opened by hand
|
|
512
|
+
* has no factory proof and never will; the full suite running for it is the
|
|
513
|
+
* designed behavior, not a mistake to report.
|
|
514
|
+
*/
|
|
515
|
+
const CORRECTIVE_LINE = "Run `psf pr:verify` before publishing to reuse local proof and avoid a duplicate server run.";
|
|
516
|
+
/**
|
|
517
|
+
* Emitted when a consumer's command selection is unusable. Mirrors the
|
|
518
|
+
* retired hosted docs-only detector: a malformed profile slice degrades to the
|
|
519
|
+
* conservative answer at *runtime* rather than throwing at generation time,
|
|
520
|
+
* because the input is JSON that may be absent or reshaped. The loud signal is
|
|
521
|
+
* the static coverage assertion, plus `reason=error` on the step output.
|
|
522
|
+
*/
|
|
523
|
+
const UNUSABLE_SELECTION_SCRIPT = String.raw`{
|
|
524
|
+
printf '${FACTORY_PROOF_GATE_OUTPUT}=false\n'
|
|
525
|
+
printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=error\n'
|
|
526
|
+
} >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"`;
|
|
527
|
+
/** Surface labels reach markdown, so only a plain, bounded label survives. */
|
|
528
|
+
const safeLabel = (surface) => {
|
|
529
|
+
const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
|
|
530
|
+
return cleaned.length > 0 ? cleaned : "verification";
|
|
531
|
+
};
|
|
532
|
+
const gateScript = (required, surface) => String.raw`
|
|
533
|
+
set -uo pipefail
|
|
534
|
+
|
|
535
|
+
CHECK_NAME=${shellSingleQuote(FACTORY_PROOF_GATE_CHECK_NAME)}
|
|
536
|
+
FACTORY_APP_ID=${shellSingleQuote(FACTORY_PROOF_GATE_APP_ID)}
|
|
537
|
+
REQUIRED_COMMANDS=${shellSingleQuote(JSON.stringify(required))}
|
|
538
|
+
SURFACE=${shellSingleQuote(surface)}
|
|
539
|
+
|
|
540
|
+
reason=error
|
|
541
|
+
detail=''
|
|
542
|
+
mode=''
|
|
543
|
+
missing=''
|
|
544
|
+
|
|
545
|
+
# filter=all with full pagination is load-bearing (ADR 0022). GitHub's
|
|
546
|
+
# default "latest" filter is ordered by completion, so a newer generation
|
|
547
|
+
# that is still running can be hidden behind an older completed one — the
|
|
548
|
+
# gate would then read a stale pass as current.
|
|
549
|
+
if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
|
|
550
|
+
detail='no pull request head SHA'
|
|
551
|
+
elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
|
|
552
|
+
detail='no repository name'
|
|
553
|
+
elif ! response=$(gh api --method GET --paginate \
|
|
554
|
+
"repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
|
|
555
|
+
-f "check_name=$CHECK_NAME" \
|
|
556
|
+
-f filter=all \
|
|
557
|
+
-f per_page=100 2>&1); then
|
|
558
|
+
detail="checks API unreadable: $response"
|
|
559
|
+
elif ! finding=$(printf '%s' "$response" | jq -r -s \
|
|
560
|
+
--arg name "$CHECK_NAME" \
|
|
561
|
+
--arg app "$FACTORY_APP_ID" \
|
|
562
|
+
--arg sha "$HEAD_SHA" \
|
|
563
|
+
--arg repository "$GITHUB_REPOSITORY" \
|
|
564
|
+
--argjson required "$REQUIRED_COMMANDS" \
|
|
565
|
+
'${GATE_JQ}' 2>&1); then
|
|
566
|
+
detail="unreadable proof binding: $finding"
|
|
567
|
+
else
|
|
568
|
+
# Three separate reads, not one IFS split: tab is IFS whitespace, so a
|
|
569
|
+
# split would collapse the empty fields the vocabulary relies on.
|
|
570
|
+
#
|
|
571
|
+
# The trailing "|| :" on each read is load-bearing. "missing" is empty for
|
|
572
|
+
# every verdict except "incomplete", so jq's third field is the empty
|
|
573
|
+
# string, command substitution strips the trailing newline, and the third
|
|
574
|
+
# read reaches EOF -- having correctly assigned "" -- and returns 1. Under
|
|
575
|
+
# an errexit shell that return value kills the step before the single
|
|
576
|
+
# GITHUB_OUTPUT write below, which is exactly how this gate failed in
|
|
577
|
+
# hosted CI on every decidable verdict while every test passed (#319).
|
|
578
|
+
{
|
|
579
|
+
IFS= read -r reason || :
|
|
580
|
+
IFS= read -r mode || :
|
|
581
|
+
IFS= read -r missing || :
|
|
582
|
+
} <<< "$finding"
|
|
583
|
+
case "$reason" in
|
|
584
|
+
proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
|
|
585
|
+
*)
|
|
586
|
+
detail="unexpected gate result: $reason"
|
|
587
|
+
reason=error
|
|
588
|
+
;;
|
|
589
|
+
esac
|
|
590
|
+
fi
|
|
591
|
+
|
|
592
|
+
# The binding is App-verified, but it still reaches markdown. Only a plain
|
|
593
|
+
# lowercase token is quoted back; anything else is reported as unknown.
|
|
594
|
+
case "$mode" in
|
|
595
|
+
'') ;;
|
|
596
|
+
*[!a-z-]*) mode='unknown' ;;
|
|
597
|
+
esac
|
|
598
|
+
|
|
599
|
+
verdict=false
|
|
600
|
+
if [ "$reason" = 'proven' ]; then
|
|
601
|
+
verdict=true
|
|
602
|
+
fi
|
|
603
|
+
|
|
604
|
+
# detail can carry arbitrary gh or jq error text. Collapse it to one bounded
|
|
605
|
+
# line so it cannot restructure the summary it is written into.
|
|
606
|
+
detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
|
|
607
|
+
missing=$(printf '%s' "$missing" | tr '\n\r\t' ' ' | cut -c1-240)
|
|
608
|
+
|
|
609
|
+
SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
|
|
610
|
+
say() { printf '%s\n' "$1" >> "$SUMMARY"; }
|
|
611
|
+
|
|
612
|
+
say "## Proof reuse: $SURFACE"
|
|
613
|
+
say ''
|
|
614
|
+
|
|
615
|
+
case "$reason" in
|
|
616
|
+
proven)
|
|
617
|
+
echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
|
|
618
|
+
say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
|
|
619
|
+
say ''
|
|
620
|
+
say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
|
|
621
|
+
say "- Covers head: \`$HEAD_SHA\`"
|
|
622
|
+
say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
|
|
623
|
+
;;
|
|
624
|
+
none)
|
|
625
|
+
say 'Ran the full suite. No trusted factory proof covers this commit.'
|
|
626
|
+
say ''
|
|
627
|
+
say "The factory GitHub App has published no \`$CHECK_NAME\` check run at head \`$HEAD_SHA\`. Proof is keyed to the commit, so proof written for any other commit is not visible here. A pull request opened by hand carries no factory proof at all, which is normal."
|
|
628
|
+
say ''
|
|
629
|
+
say '${CORRECTIVE_LINE}'
|
|
630
|
+
;;
|
|
631
|
+
pending)
|
|
632
|
+
say 'Ran the full suite. The factory proof for this commit had not finished when this job read it.'
|
|
633
|
+
say ''
|
|
634
|
+
say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` was still in progress. The gate reads one snapshot at job start and does not wait."
|
|
635
|
+
say ''
|
|
636
|
+
say "Let \`psf pr:verify\` finish before publishing, so its proof is on the commit before this job reads it."
|
|
637
|
+
;;
|
|
638
|
+
failed)
|
|
639
|
+
say 'Ran the full suite. The factory proof for this commit records no pass.'
|
|
640
|
+
say ''
|
|
641
|
+
say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` finished without a passing conclusion, so it establishes nothing about this tree."
|
|
642
|
+
say ''
|
|
643
|
+
say '${CORRECTIVE_LINE}'
|
|
644
|
+
;;
|
|
645
|
+
unreadable)
|
|
646
|
+
say 'Ran the full suite. The factory check run for this commit carries no proof this job can read.'
|
|
647
|
+
say ''
|
|
648
|
+
say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but it carries no machine-readable binding naming this repository and this head, or one written to a schema this job does not recognize."
|
|
649
|
+
say ''
|
|
650
|
+
say '${CORRECTIVE_LINE}'
|
|
651
|
+
;;
|
|
652
|
+
incomplete)
|
|
653
|
+
say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
|
|
654
|
+
say ''
|
|
655
|
+
say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`executedCommands\` is missing: \`$missing\`"
|
|
656
|
+
say ''
|
|
657
|
+
say '${CORRECTIVE_LINE}'
|
|
658
|
+
;;
|
|
659
|
+
ambiguous)
|
|
660
|
+
say 'Ran the full suite. The newest factory proof for this commit is ambiguous.'
|
|
661
|
+
say ''
|
|
662
|
+
say "Two \`$CHECK_NAME\` check runs at head \`$HEAD_SHA\` share the greatest start time, so there is no single newest generation to trust. The gate refuses rather than picking one."
|
|
663
|
+
say ''
|
|
664
|
+
say '${CORRECTIVE_LINE}'
|
|
665
|
+
;;
|
|
666
|
+
*)
|
|
667
|
+
say 'Ran the full suite. The proof gate could not reach a decision.'
|
|
668
|
+
say ''
|
|
669
|
+
say 'The gate fails open by design: when it cannot tell whether proof exists, every command runs. Nothing about this pull request is implied.'
|
|
670
|
+
say ''
|
|
671
|
+
say " $detail"
|
|
672
|
+
;;
|
|
673
|
+
esac
|
|
674
|
+
say ''
|
|
675
|
+
|
|
676
|
+
if [ "$verdict" != 'true' ]; then
|
|
677
|
+
echo "No reusable factory proof for ${shellExpansion("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
|
|
678
|
+
fi
|
|
679
|
+
|
|
680
|
+
# The only write. A crash before this line leaves the output unset, the guard
|
|
681
|
+
# reads empty, and every guarded step runs — the fail-open path.
|
|
682
|
+
{
|
|
683
|
+
printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
|
|
684
|
+
printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
|
|
685
|
+
printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
|
|
686
|
+
} >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"
|
|
687
|
+
`.trim();
|
|
688
|
+
/**
|
|
689
|
+
* The gate script. Exported so it can be executed directly under test against
|
|
690
|
+
* a stubbed `gh`, rather than only asserted against as workflow text — the
|
|
691
|
+
* script is the security boundary, and asserting on generated YAML would leave
|
|
692
|
+
* its actual decisions untested.
|
|
693
|
+
*
|
|
694
|
+
* Note the deliberate absence of `set -e`: every failure path has to reach the
|
|
695
|
+
* final write, and that write must emit `false`. Omitting it is necessary but
|
|
696
|
+
* not sufficient — the runner supplies `-e` unless the step declares
|
|
697
|
+
* `FACTORY_PROOF_GATE_SHELL` — so the script is also written to reach that
|
|
698
|
+
* write under errexit, and the tests execute it both ways.
|
|
699
|
+
*/
|
|
700
|
+
const factoryProofGateScript = ({ commands, surface }) => {
|
|
701
|
+
const required = proofReuseRequiredCommands(commands);
|
|
702
|
+
return required ? gateScript(required, safeLabel(surface)) : UNUSABLE_SELECTION_SCRIPT;
|
|
703
|
+
};
|
|
704
|
+
/**
|
|
705
|
+
* The step itself, structurally accepted by gagen's `step()` without adding a
|
|
706
|
+
* gagen runtime dependency. It belongs first in the job it guards: one Checks
|
|
707
|
+
* API read with the default `GITHUB_TOKEN` (`checks: read`), no checkout, no
|
|
708
|
+
* install, so a proven head costs a runner nothing beyond job startup.
|
|
709
|
+
*
|
|
710
|
+
* A **step, not a job**, and that is not a style preference. A separate gate
|
|
711
|
+
* job that errored would leave the guarded job `skipped`, and a summary job
|
|
712
|
+
* that only fails on `failure`/`cancelled` would still report green — a Checks
|
|
713
|
+
* API outage would silently skip verification. As a step it fails open by
|
|
714
|
+
* construction: the step errors, the output is never written, the guard reads
|
|
715
|
+
* empty, and every command runs.
|
|
716
|
+
*/
|
|
717
|
+
const factoryProofGateStep = (options) => Object.freeze({
|
|
718
|
+
continueOnError: true,
|
|
719
|
+
env: Object.freeze({
|
|
720
|
+
GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
|
|
721
|
+
HEAD_SHA: githubExpression("github.event.pull_request.head.sha")
|
|
722
|
+
}),
|
|
723
|
+
id: FACTORY_PROOF_GATE_STEP_ID,
|
|
724
|
+
if: FACTORY_PROOF_GATE_IF,
|
|
725
|
+
name: "Check for factory proof of this head",
|
|
726
|
+
run: factoryProofGateScript(options),
|
|
727
|
+
shell: FACTORY_PROOF_GATE_SHELL
|
|
728
|
+
});
|
|
729
|
+
/**
|
|
730
|
+
* The static coverage assertion, as a report.
|
|
731
|
+
*
|
|
732
|
+
* Runtime command coverage is authoritative — the gate's `incomplete` refusal
|
|
733
|
+
* is the backstop that cannot be bypassed. This is the compile-time guard in
|
|
734
|
+
* front of it: it fails a consumer's build the moment a workflow skips work
|
|
735
|
+
* that no selected profile command runs, rather than letting the drift show up
|
|
736
|
+
* as CI quietly verifying nothing. The drift is real history: #227 wired a
|
|
737
|
+
* workspace member into CI while the profile never learned about it.
|
|
738
|
+
*/
|
|
739
|
+
const proofReuseCoverage = ({ commands, equivalents = {}, skipped }) => {
|
|
740
|
+
const requiredCommands = proofReuseRequiredCommands(commands) ?? [];
|
|
741
|
+
const proven = new Set(requiredCommands.length > 0 ? commands.map(({ command }) => command.trim()) : []);
|
|
742
|
+
const uncovered = [...new Set((Array.isArray(skipped) ? skipped : []).map((command) => String(command).trim()).filter((command) => command.length > 0 && !(proven.has(command) || Object.hasOwn(equivalents, command))))].toSorted();
|
|
743
|
+
return Object.freeze({
|
|
744
|
+
covered: requiredCommands.length > 0 && uncovered.length === 0,
|
|
745
|
+
requiredCommands,
|
|
746
|
+
uncovered
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
/** `proofReuseCoverage`, as a build failure. */
|
|
750
|
+
const assertProofReuseCoverage = (input) => {
|
|
751
|
+
const report = proofReuseCoverage(input);
|
|
752
|
+
if (report.covered) return report;
|
|
753
|
+
const surface = safeLabel(input.surface ?? "");
|
|
754
|
+
const problem = report.requiredCommands.length === 0 ? "selects no usable profile commands, so any passing proof would trivially cover it" : `skips work no selected profile command runs: ${report.uncovered.join(", ")}`;
|
|
755
|
+
throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
|
|
756
|
+
};
|
|
757
|
+
//#endregion
|
|
758
|
+
export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, NODE_PNPM_ACTION_FAMILY_NODE24, assertProofReuseCoverage, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/factory-ci",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, Alchemy entry execution, and disposable-stage semantics",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"alchemy",
|
|
7
7
|
"cloudflare",
|
package/src/index.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* repeat across Patronage factory projects (#266, #268).
|
|
4
4
|
*
|
|
5
5
|
* A pure library: no `bin`, no `alchemy` or `effect` dependency, no config
|
|
6
|
-
* surface of its own. Everything takes
|
|
7
|
-
* `software-factory.profile.json`
|
|
8
|
-
*
|
|
6
|
+
* surface of its own. Everything takes plain typed values; callers pass
|
|
7
|
+
* canonical policy from `software-factory.profile.json` without this package
|
|
8
|
+
* loading or owning that profile (ADR 0021).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export {
|
|
@@ -41,3 +41,26 @@ export {
|
|
|
41
41
|
type ExecuteAlchemyEntryResult,
|
|
42
42
|
executeAlchemyEntry,
|
|
43
43
|
} from "./execute-alchemy-entry.ts";
|
|
44
|
+
export {
|
|
45
|
+
assertProofReuseCoverage,
|
|
46
|
+
FACTORY_PROOF_GATE_APP_ID,
|
|
47
|
+
FACTORY_PROOF_GATE_CHECK_NAME,
|
|
48
|
+
FACTORY_PROOF_GATE_GUARD,
|
|
49
|
+
FACTORY_PROOF_GATE_IF,
|
|
50
|
+
FACTORY_PROOF_GATE_MODE_OUTPUT,
|
|
51
|
+
FACTORY_PROOF_GATE_OUTPUT,
|
|
52
|
+
FACTORY_PROOF_GATE_REASON_OUTPUT,
|
|
53
|
+
FACTORY_PROOF_GATE_REASONS,
|
|
54
|
+
FACTORY_PROOF_GATE_SHELL,
|
|
55
|
+
FACTORY_PROOF_GATE_STEP_ID,
|
|
56
|
+
type FactoryProofGateOptions,
|
|
57
|
+
type FactoryProofGateReason,
|
|
58
|
+
factoryProofGateScript,
|
|
59
|
+
type FactoryProofGateStep,
|
|
60
|
+
factoryProofGateStep,
|
|
61
|
+
type ProofReuseCommand,
|
|
62
|
+
proofReuseCoverage,
|
|
63
|
+
type ProofReuseCoverageInput,
|
|
64
|
+
type ProofReuseCoverageReport,
|
|
65
|
+
proofReuseRequiredCommands,
|
|
66
|
+
} from "./proof-reuse-gate.ts";
|