@patronage/factory-ci 1.0.0-alpha.21 → 1.0.0-alpha.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "1.0.0-alpha.21",
3
+ "version": "1.0.0-alpha.23",
4
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",
@@ -48,7 +48,7 @@
48
48
  "node": "^24.0.0"
49
49
  },
50
50
  "scripts": {
51
- "cleanup:check": "knip --files --dependencies",
51
+ "cleanup:check": "knip --files --dependencies --exports",
52
52
  "prebuild": "bash ../scripts/ensure-worktree-bootstrap.sh",
53
53
  "build": "tsdown",
54
54
  "precheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
package/src/actions.ts CHANGED
@@ -52,3 +52,16 @@ export const NODE_PNPM_ACTION_FAMILY_NODE24 = {
52
52
  uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271",
53
53
  },
54
54
  } as const satisfies NodePnpmActionFamily;
55
+
56
+ /**
57
+ * The one `actions/upload-artifact` pin every workflow generator shares
58
+ * (#887). Two callers pinned two different commits of the same tag family;
59
+ * naming the pin here makes that drift impossible the same way the Node/pnpm
60
+ * family does. A caller passes this through `factoryWorkflow`'s
61
+ * `additionalActions.uploadArtifact` explicitly — the catalog names the pin,
62
+ * it does not inject the upload step.
63
+ */
64
+ export const UPLOAD_ARTIFACT = {
65
+ tag: "v4.6.2",
66
+ uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
67
+ } as const satisfies PinnedAction;
@@ -1,4 +1,5 @@
1
1
  import type { WorkflowStep } from "./factory-workflow.ts";
2
+ import { impactDemandConditions } from "./impact-demand-condition.ts";
2
3
  import { productionImpactTargetOutput } from "./production-impact-workflow.ts";
3
4
 
4
5
  export const FACTORY_CANDIDATE_IMPACT_STEP_ID = "candidate_impact";
@@ -23,8 +24,13 @@ export interface FactoryCandidateImpactWorkflow {
23
24
  /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
24
25
  readonly decisionJobOutputs: Readonly<Record<string, string>>;
25
26
  readonly decisionStep: WorkflowStep;
26
- /** A fail-open condition: only an explicit usable withdrawal skips work. */
27
- readonly demandedIf: (targetName: string, decisionJob?: string) => string;
27
+ /**
28
+ * A fail-open cross-job condition: only an explicit usable withdrawal skips
29
+ * work. The decision job id is required.
30
+ */
31
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
32
+ /** The same fail-open condition for a step in the decision job. */
33
+ readonly demandedIfAtStep: (targetName: string) => string;
28
34
  readonly targetOutputs: Readonly<Record<string, string>>;
29
35
  }
30
36
 
@@ -74,6 +80,13 @@ export const factoryCandidateImpactWorkflow = (
74
80
  run: `${cli} candidate:impact --base "$FACTORY_BASE_SHA" --head "$FACTORY_HEAD_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`,
75
81
  };
76
82
 
83
+ const conditions = impactDemandConditions({
84
+ decisionOutput: FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT,
85
+ label: "candidate",
86
+ stepId: FACTORY_CANDIDATE_IMPACT_STEP_ID,
87
+ targetOutputs,
88
+ });
89
+
77
90
  return Object.freeze({
78
91
  decisionJobOutputs: Object.freeze({
79
92
  basis: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT} }}`,
@@ -88,18 +101,8 @@ export const factoryCandidateImpactWorkflow = (
88
101
  ),
89
102
  }),
90
103
  decisionStep: Object.freeze(decisionStep),
91
- demandedIf: (targetName: string, decisionJob?: string) => {
92
- const output = targetOutputs[targetName];
93
- if (output === undefined) {
94
- throw new Error(`Unknown candidate impact target "${targetName}".`);
95
- }
96
- const source = decisionJob
97
- ? `needs.${decisionJob}`
98
- : `steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}`;
99
- const outcome = decisionJob ? "result" : "outcome";
100
- const condition = `${source}.${outcome} != 'success' || ${source}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
101
- return decisionJob ? `always() && (${condition})` : condition;
102
- },
104
+ demandedIf: conditions.demandedIf,
105
+ demandedIfAtStep: conditions.demandedIfAtStep,
103
106
  targetOutputs: Object.freeze(targetOutputs),
104
107
  });
105
108
  };
@@ -27,22 +27,12 @@ export const setExecuteAlchemyEntrySpawnForTests = (
27
27
  spawnImplementation = next ?? spawnSync;
28
28
  };
29
29
 
30
- export interface ExecuteAlchemyEntryOptions {
30
+ interface ExecuteAlchemyEntryCommon {
31
31
  /**
32
32
  * CLI arguments before the bundled entry. The absolute entry path is always
33
33
  * appended as the final argument.
34
34
  */
35
35
  readonly args: readonly string[];
36
- /**
37
- * Bundle the consumer entry before execution. Required unless `bundledEntry`
38
- * is set. Ignored when `bundledEntry` is set.
39
- */
40
- readonly bundle?: BundleAlchemyEntryOptions;
41
- /**
42
- * Already-bundled entry path. When set, skips the internal bundle step and
43
- * appends this path as the CLI's final argument.
44
- */
45
- readonly bundledEntry?: string;
46
36
  /** Child working directory. Defaults to the bundle root, then process.cwd. */
47
37
  readonly cwd?: string;
48
38
  /** Child environment. Defaults to process.env. */
@@ -66,6 +56,27 @@ export interface ExecuteAlchemyEntryOptions {
66
56
  readonly stdio?: "inherit" | "pipe";
67
57
  }
68
58
 
59
+ /**
60
+ * Exactly one entry source. The union refuses `bundle` and `bundledEntry`
61
+ * together, and refuses neither, so no runtime check is needed.
62
+ */
63
+ export type ExecuteAlchemyEntryOptions = ExecuteAlchemyEntryCommon &
64
+ (
65
+ | {
66
+ /** Bundle the consumer entry before execution. */
67
+ readonly bundle: BundleAlchemyEntryOptions;
68
+ readonly bundledEntry?: never;
69
+ }
70
+ | {
71
+ readonly bundle?: never;
72
+ /**
73
+ * Already-bundled entry path. Skips the internal bundle step and
74
+ * appends this path as the CLI's final argument.
75
+ */
76
+ readonly bundledEntry: string;
77
+ }
78
+ );
79
+
69
80
  export interface ExecuteAlchemyEntryResult {
70
81
  readonly bundledEntry: string;
71
82
  readonly stderr: string | null;
@@ -75,16 +86,10 @@ export interface ExecuteAlchemyEntryResult {
75
86
  const resolveBundledEntry = async (
76
87
  options: ExecuteAlchemyEntryOptions
77
88
  ): Promise<string> => {
78
- if (options.bundledEntry !== undefined) {
79
- return path.resolve(
80
- options.cwd ?? options.bundle?.absWorkingDir ?? process.cwd(),
81
- options.bundledEntry
82
- );
83
- }
84
- if (options.bundle === undefined) {
85
- throw new Error("executeAlchemyEntry requires bundle or bundledEntry.");
89
+ if (options.bundledEntry === undefined) {
90
+ return await bundleAlchemyEntry(options.bundle);
86
91
  }
87
- return await bundleAlchemyEntry(options.bundle);
92
+ return path.resolve(options.cwd ?? process.cwd(), options.bundledEntry);
88
93
  };
89
94
 
90
95
  const captured = (
@@ -101,8 +106,8 @@ const captured = (
101
106
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
102
107
  * run it under the current Node binary, and return only on a successful exit.
103
108
  *
104
- * Pass `bundledEntry` to skip the bundle step and reuse an already-bundled
105
- * path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
109
+ * Options carry exactly one entry source: `bundle` to bundle now, or
110
+ * `bundledEntry` to reuse an already-bundled path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
106
111
  * `redact` before they are returned; `stdio: "inherit"` still streams the
107
112
  * child unredacted.
108
113
  *
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Per-target withdrawal conditions for the candidate and production impact
3
+ * routing seams. Both seams emit the same condition text, so one
4
+ * implementation owns it.
5
+ *
6
+ * A cross-job condition and a same-job condition read different GitHub
7
+ * expression sources. `demandedIf` requires the decision job id and reads
8
+ * `needs.<job>`; `demandedIfAtStep` reads `steps.<stepId>` and takes no job
9
+ * id. The caller picks the seam by name, so an omitted job id can no longer
10
+ * emit a same-job condition where a cross-job condition was meant.
11
+ */
12
+
13
+ interface ImpactDemandConditionOptions {
14
+ /** Output key that carries the decision verdict. */
15
+ readonly decisionOutput: string;
16
+ /** Seam name used in the unknown-target error. */
17
+ readonly label: string;
18
+ /** Id of the decision step inside the decision job. */
19
+ readonly stepId: string;
20
+ /** Target name to GitHub output key. */
21
+ readonly targetOutputs: Readonly<Record<string, string>>;
22
+ }
23
+
24
+ interface ImpactDemandConditions {
25
+ /**
26
+ * Cross-job condition for a job that lists the decision job in `needs`.
27
+ * The decision job id is required.
28
+ */
29
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
30
+ /** Same-job condition for a step that follows the decision step. */
31
+ readonly demandedIfAtStep: (targetName: string) => string;
32
+ }
33
+
34
+ export const impactDemandConditions = (
35
+ options: ImpactDemandConditionOptions
36
+ ): ImpactDemandConditions => {
37
+ const targetOutput = (targetName: string): string => {
38
+ const output = options.targetOutputs[targetName];
39
+ if (output === undefined) {
40
+ throw new Error(
41
+ `Unknown ${options.label} impact target "${targetName}".`
42
+ );
43
+ }
44
+ return output;
45
+ };
46
+
47
+ const condition = (
48
+ source: string,
49
+ outcomeKey: string,
50
+ output: string
51
+ ): string =>
52
+ `${source}.${outcomeKey} != 'success' || ${source}.outputs.${options.decisionOutput} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
53
+
54
+ return Object.freeze({
55
+ demandedIf: (targetName: string, decisionJob: string) => {
56
+ const output = targetOutput(targetName);
57
+ if (decisionJob.trim().length === 0) {
58
+ throw new Error(
59
+ `A cross-job ${options.label} impact condition requires the decision job id.`
60
+ );
61
+ }
62
+ return `always() && (${condition(`needs.${decisionJob}`, "result", output)})`;
63
+ },
64
+ demandedIfAtStep: (targetName: string) =>
65
+ condition(`steps.${options.stepId}`, "outcome", targetOutput(targetName)),
66
+ });
67
+ };
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  NODE_PNPM_ACTION_FAMILY_NODE24,
13
13
  type NodePnpmActionFamily,
14
14
  type PinnedAction,
15
+ UPLOAD_ARTIFACT,
15
16
  } from "./actions.ts";
16
17
  export {
17
18
  FACTORY_CANDIDATE_PULL_REQUEST_TYPES,
@@ -43,6 +44,7 @@ export {
43
44
  } from "./preview-proof-lifecycle.ts";
44
45
  export {
45
46
  PREVIEW_PROOF_INVENTORY_CHECK_NAME,
47
+ PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS,
46
48
  previewProofInventory,
47
49
  type PreviewProofInventoryAccess,
48
50
  type PreviewProofInventoryCleanupInput,
@@ -83,6 +85,7 @@ export {
83
85
  FACTORY_PROOF_GATE_IF,
84
86
  FACTORY_PROOF_GATE_MODE_OUTPUT,
85
87
  FACTORY_PROOF_GATE_OUTPUT,
88
+ FACTORY_PROOF_GATE_PULL_REQUEST_IF,
86
89
  FACTORY_PROOF_GATE_REASON_OUTPUT,
87
90
  FACTORY_PROOF_GATE_REASONS,
88
91
  FACTORY_PROOF_GATE_SHELL,
@@ -91,6 +94,7 @@ export {
91
94
  FACTORY_PROOF_GATE_STEP_NAME,
92
95
  type FactoryProofGateOptions,
93
96
  type FactoryProofGateReason,
97
+ type FactoryProofGateReuse,
94
98
  factoryProofGateScript,
95
99
  type FactoryProofGateStep,
96
100
  factoryProofGateStep,
@@ -125,6 +129,20 @@ export {
125
129
  type FactoryLifecycleContractLaneOptions,
126
130
  factoryLifecycleContractLane,
127
131
  } from "./lifecycle-contract-lane.ts";
132
+ export {
133
+ FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID,
134
+ FACTORY_MERGE_FREEZE_CHECK_NAME,
135
+ FACTORY_MERGE_FREEZE_IF,
136
+ FACTORY_MERGE_FREEZE_JOB_ID,
137
+ FACTORY_MERGE_FREEZE_JOB_NAME,
138
+ FACTORY_MERGE_FREEZE_PERMISSIONS,
139
+ FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION,
140
+ FACTORY_MERGE_TARGET_REF_CONDITION,
141
+ type FactoryMergeFreezeJob,
142
+ factoryMergeFreezeJob,
143
+ type FactoryMergeFreezeJobOptions,
144
+ factoryMergeFreezeScript,
145
+ } from "./merge-freeze-job.ts";
128
146
  export {
129
147
  FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID,
130
148
  FACTORY_PR_STATUS_HUD_CONCURRENCY,
@@ -0,0 +1,237 @@
1
+ import type { PinnedAction } from "./actions.ts";
2
+ import type { WorkflowStep } from "./factory-workflow.ts";
3
+ import { assertPinnedAction } from "./pinned-action.ts";
4
+ import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
5
+
6
+ /**
7
+ * The merge-freeze writer job (#356, ADR 0016 as amended; #429 for the
8
+ * merge-target namespace; #872 for this package).
9
+ *
10
+ * A consumer's generated merge-target-push Verify workflow is the ONLY
11
+ * producer of `patronage-factory/merge-freeze` generations: a red
12
+ * push-triggered Verify completes a generation active, a green one completes
13
+ * it inactive, and no command, scheduled job, or second workflow writes this
14
+ * check run. The operator override (`demand:waive --demand merge-freeze`)
15
+ * waives the demand for one candidate during a fix-forward; it never writes
16
+ * here.
17
+ *
18
+ * The write mirrors what the factory package's merge-freeze reader parses:
19
+ * create the run `in_progress` with no `started_at` (GitHub stamps the
20
+ * generation clock — the ordering rule itself is owned by the reader), then
21
+ * PATCH it completed with the reader-shaped state payload in `output.text`.
22
+ * The consumer's workflow test executes this exact script against a stubbed
23
+ * `gh` and parses the emitted payload with `validateMergeFreezeState`, so
24
+ * writer and reader cannot drift silently.
25
+ *
26
+ * Those two API calls are what the reader's two settling phases observe
27
+ * (#890). Before the POST the merge target carries no generation at all, which
28
+ * a reader reports as `awaiting-generation` while the pushed hosted verify run
29
+ * is still pending. Between the POST and the PATCH the generation exists with
30
+ * no `output.text`, which a reader reports as `running-generation`. Only the
31
+ * PATCH writes a verdict, so neither phase is one.
32
+ *
33
+ * Failure posture is deliberately fail-closed and the opposite of the
34
+ * proof-reuse gate's fail-open shell: this step runs under the runner's
35
+ * default `bash -e {0}`, so any API refusal aborts the step, the generation
36
+ * stays absent or `in_progress`, and every `pr:ready` arming-time read of
37
+ * that tip (#477) refuses until a later push writes a green generation.
38
+ *
39
+ * **Ownership line.** This package owns the check name, the App identity, the
40
+ * `create-github-app-token` inputs, the result fold, the merge-target `if`,
41
+ * and the job's `permissions`. The caller owns the runner, the `needs` list
42
+ * of every authoritative verification leaf, the timeout, and any extra env.
43
+ */
44
+
45
+ /** The one check-run name every merge-freeze reader pins. */
46
+ export const FACTORY_MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
47
+
48
+ export const FACTORY_MERGE_FREEZE_JOB_ID = "freeze";
49
+ export const FACTORY_MERGE_FREEZE_JOB_NAME = "Report the merge freeze";
50
+
51
+ /** Step id the token step exposes its installation token under. */
52
+ export const FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID = "factory-app-token";
53
+
54
+ /**
55
+ * The GitHub Actions expression that folds the needed jobs' results into the
56
+ * one word the script branches on. It mirrors the `verify` summary job's
57
+ * failure condition exactly: any failed or cancelled needed job is a red
58
+ * merge target.
59
+ */
60
+ export const FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION = `\${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }}`;
61
+
62
+ /**
63
+ * The literal factory merge-target namespace, as a `github.ref` predicate
64
+ * (#429).
65
+ *
66
+ * `main` plus the repository-owned `epic/**` integration branches, and
67
+ * nothing else. Deliberately literal rather than profile-declared: one
68
+ * implementation per contract, no configuration machinery for a two-element
69
+ * namespace whose second element is a namespace prefix the repository itself
70
+ * owns. `epic/` needs the trailing slash — `startsWith` would otherwise admit
71
+ * `epicness/…`, which is not a merge target.
72
+ *
73
+ * A caller's `push` trigger branch list names the same namespace. That list
74
+ * decides which events produce a run at all; this predicate decides which of
75
+ * those runs may write a generation.
76
+ */
77
+ export const FACTORY_MERGE_TARGET_REF_CONDITION = [
78
+ "github.ref == 'refs/heads/main'",
79
+ "startsWith(github.ref, 'refs/heads/epic/')",
80
+ ].join(" || ");
81
+
82
+ /**
83
+ * `always()` is load-bearing: the red path is the whole point, so the job must
84
+ * run when a needed verification job failed. `github.event_name == 'push'` is
85
+ * load-bearing twice over: a `pull_request` run must never write a generation
86
+ * (its `github.sha` is a synthetic merge commit that no base tip is ever read
87
+ * at), and a `pull_request` trigger with an `epic/**` base list would
88
+ * otherwise reach this job.
89
+ */
90
+ export const FACTORY_MERGE_FREEZE_IF = `always() && github.event_name == 'push' && (${FACTORY_MERGE_TARGET_REF_CONDITION})`;
91
+
92
+ /**
93
+ * Least privilege for the writer job: no `GITHUB_TOKEN` scope at all.
94
+ *
95
+ * Both Checks API calls run under the App installation token minted in the
96
+ * first step (`GH_TOKEN: steps.factory-app-token.outputs.token`), and the job
97
+ * never checks out the repository. So the job uses no `GITHUB_TOKEN` scope,
98
+ * and an empty block is the honest declaration. A `checks: write` here would
99
+ * hand every adopter an unused write scope (#884).
100
+ */
101
+ export const FACTORY_MERGE_FREEZE_PERMISSIONS = Object.freeze({} as const);
102
+
103
+ /**
104
+ * The script the freeze step runs. Environment contract:
105
+ * - `GH_TOKEN`: an installation token for the pinned Patronage Factory App —
106
+ * the check run must carry that App's identity or every reader rejects it.
107
+ * - `VERIFY_RESULT`: `success` or `failure` (see the fold expression above).
108
+ * - `GITHUB_SHA` / `GITHUB_REPOSITORY` / `GITHUB_SERVER_URL` /
109
+ * `GITHUB_RUN_ID` / `GITHUB_REF_NAME`: runner-provided.
110
+ *
111
+ * `GITHUB_REF_NAME` is the merge target this run pushed to — `main` or an
112
+ * `epic/**` integration branch (#429). The job is gated to `push` events on
113
+ * exactly those refs, so the branch name is always the short name of a
114
+ * repository-owned merge target and never a pull-request merge ref. It
115
+ * appears in both recovery sentences because a reader of an epic-targeting
116
+ * candidate must be told which branch to fix forward on: the generation lives
117
+ * on that branch's tip, and only a later green push to THAT branch clears it.
118
+ */
119
+ export const factoryMergeFreezeScript = (): string =>
120
+ [
121
+ `sha="$GITHUB_SHA"`,
122
+ `branch="$GITHUB_REF_NAME"`,
123
+ `repo="$GITHUB_REPOSITORY"`,
124
+ `run_url="$GITHUB_SERVER_URL/$repo/actions/runs/$GITHUB_RUN_ID"`,
125
+ `created=$(gh api --method POST "repos/$repo/check-runs" \\`,
126
+ ` -f "name=${FACTORY_MERGE_FREEZE_CHECK_NAME}" \\`,
127
+ ` -f "head_sha=$sha" \\`,
128
+ ` -f "status=in_progress" \\`,
129
+ ` -f "details_url=$run_url" \\`,
130
+ ` -f "output[title]=merge freeze generation in progress" \\`,
131
+ ` -f "output[summary]=This generation blocks factory merge handoffs until the merge-target Verify result is recorded.")`,
132
+ `id=$(jq -r '.id' <<<"$created")`,
133
+ `case "$id" in`,
134
+ ` ''|*[!0-9]*) echo "unusable check-run id: $id" >&2; exit 1;;`,
135
+ `esac`,
136
+ `if [ "$VERIFY_RESULT" = 'success' ]; then`,
137
+ ` active=false; outcome=inactive; conclusion=success`,
138
+ ` reason="Merge-target Verify passed on $branch at $sha ($run_url)."`,
139
+ `else`,
140
+ ` active=true; outcome=active; conclusion=failure`,
141
+ ` reason="Merge-target Verify failed on $branch at $sha; fix forward or revert on $branch and let the next green Verify push on $branch clear this freeze ($run_url)."`,
142
+ `fi`,
143
+ `state=$(jq -cn --argjson active "$active" --argjson id "$id" --arg headSha "$sha" --arg outcome "$outcome" --arg reason "$reason" \\`,
144
+ ` '{active: $active, generationId: $id, headSha: $headSha, outcome: $outcome, reason: $reason, recordedAt: (now | todate), schemaVersion: 1}')`,
145
+ `gh api --method PATCH "repos/$repo/check-runs/$id" \\`,
146
+ ` -f "status=completed" \\`,
147
+ ` -f "conclusion=$conclusion" \\`,
148
+ ` -f "details_url=$run_url" \\`,
149
+ ` -f "output[title]=merge freeze $outcome" \\`,
150
+ ` -f "output[summary]=$reason" \\`,
151
+ ` -f "output[text]=$state" >/dev/null`,
152
+ `echo "merge-freeze generation $id completed $outcome on $branch at $sha"`,
153
+ ].join("\n");
154
+
155
+ export interface FactoryMergeFreezeJobOptions<TNeed> {
156
+ /** The pinned `actions/create-github-app-token` the caller declared. */
157
+ readonly createGithubAppToken: PinnedAction;
158
+ /**
159
+ * Every authoritative verification leaf this generation folds. The elements
160
+ * are whatever the caller's workflow generator uses for `needs:`; they are
161
+ * returned unchanged.
162
+ */
163
+ readonly needs: readonly TNeed[];
164
+ /**
165
+ * Override the result fold. Defaults to
166
+ * `FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION`, which is what a caller
167
+ * whose summary job folds `needs.*.result` wants. Supply one only when the
168
+ * caller's red condition is genuinely different.
169
+ */
170
+ readonly verifyResultExpression?: string;
171
+ }
172
+
173
+ export interface FactoryMergeFreezeJob<TNeed> {
174
+ readonly if: typeof FACTORY_MERGE_FREEZE_IF;
175
+ readonly jobId: typeof FACTORY_MERGE_FREEZE_JOB_ID;
176
+ readonly jobName: typeof FACTORY_MERGE_FREEZE_JOB_NAME;
177
+ readonly needs: readonly TNeed[];
178
+ readonly permissions: typeof FACTORY_MERGE_FREEZE_PERMISSIONS;
179
+ readonly steps: readonly WorkflowStep[];
180
+ }
181
+
182
+ /**
183
+ * Build the merge-freeze writer job.
184
+ *
185
+ * An empty `needs` list is refused: a fold over no verification leaf is
186
+ * always `success`, so such a job would write a green generation for a merge
187
+ * target nothing verified — the exact failure the freeze exists to prevent.
188
+ */
189
+ export const factoryMergeFreezeJob = <TNeed>(
190
+ options: FactoryMergeFreezeJobOptions<TNeed>
191
+ ): FactoryMergeFreezeJob<TNeed> => {
192
+ assertPinnedAction(
193
+ "createGithubAppToken",
194
+ options.createGithubAppToken,
195
+ "actions/create-github-app-token"
196
+ );
197
+ if (options.needs.length === 0) {
198
+ throw new Error(
199
+ "factoryMergeFreezeJob needs at least one verification job: a fold over no needed job always reports success, so the freeze would report a merge target nothing verified as green."
200
+ );
201
+ }
202
+
203
+ // Same unscoped mint as the PR status HUD job. A subset (checks/issues/PRs)
204
+ // 422s: those permissions are not granted to this installation.
205
+ const mintStep: WorkflowStep = Object.freeze({
206
+ id: FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID,
207
+ name: "Mint the factory App token",
208
+ uses: options.createGithubAppToken.uses,
209
+ with: Object.freeze({
210
+ // `client-id` superseded `app-id` in v3.2.0 (#581, #592): both inputs
211
+ // reach the same underlying `createAppAuth({ appId })` call, which has
212
+ // always accepted either the numeric App id or the Client id, so a
213
+ // pinned App id keeps authenticating unchanged.
214
+ "client-id": FACTORY_PROOF_GATE_APP_ID,
215
+ "private-key": `\${{ secrets.FACTORY_GITHUB_APP_PRIVATE_KEY }}`,
216
+ }),
217
+ });
218
+ const reportStep: WorkflowStep = Object.freeze({
219
+ env: Object.freeze({
220
+ GH_TOKEN: `\${{ steps.${FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID}.outputs.token }}`,
221
+ VERIFY_RESULT:
222
+ options.verifyResultExpression ??
223
+ FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION,
224
+ }),
225
+ name: "Complete the merge-freeze generation",
226
+ run: factoryMergeFreezeScript(),
227
+ });
228
+
229
+ return Object.freeze({
230
+ if: FACTORY_MERGE_FREEZE_IF,
231
+ jobId: FACTORY_MERGE_FREEZE_JOB_ID,
232
+ jobName: FACTORY_MERGE_FREEZE_JOB_NAME,
233
+ needs: Object.freeze([...options.needs]),
234
+ permissions: FACTORY_MERGE_FREEZE_PERMISSIONS,
235
+ steps: Object.freeze([mintStep, reportStep]),
236
+ });
237
+ };
@@ -1,6 +1,7 @@
1
1
  import type { PinnedAction } from "./actions.ts";
2
2
  import type { WorkflowStep } from "./factory-workflow.ts";
3
3
  import { assertPinnedAction } from "./pinned-action.ts";
4
+ import { PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS } from "./preview-proof-inventory.ts";
4
5
  import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
5
6
 
6
7
  export const FACTORY_PR_STATUS_HUD_JOB_ID = "status-hud";
@@ -23,15 +24,12 @@ export interface FactoryPrStatusHudWorkflowOptions {
23
24
  }
24
25
 
25
26
  /**
26
- * Least privilege for the job that runs the present step. A `permissions`
27
- * block zeroes every unlisted GITHUB_TOKEN scope, and the inventory list
28
- * reads pull requests, commits, and check-runs with that token (#825).
27
+ * Least privilege for the job that runs the present step. The present step
28
+ * is an inventory `list`, so the job needs exactly the inventory's read
29
+ * permissions (#825, #859).
29
30
  */
30
- export const FACTORY_PR_STATUS_HUD_PERMISSIONS = Object.freeze({
31
- checks: "read",
32
- contents: "read",
33
- "pull-requests": "read",
34
- } as const);
31
+ export const FACTORY_PR_STATUS_HUD_PERMISSIONS =
32
+ PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS;
35
33
 
36
34
  export const FACTORY_PR_STATUS_HUD_CONCURRENCY = Object.freeze({
37
35
  cancelInProgress: false,
@@ -35,6 +35,21 @@ import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
35
35
  export const PREVIEW_PROOF_INVENTORY_CHECK_NAME =
36
36
  "patronage-factory/preview-proof";
37
37
 
38
+ /**
39
+ * Least privilege for a job whose `GITHUB_TOKEN` calls `list`. A
40
+ * `permissions` block zeroes every unlisted scope, and `list` reads five
41
+ * endpoints: `pulls/{pr}`, `pulls/{pr}/commits`, and the GraphQL force-push
42
+ * timeline (`pull-requests`), `compare` (`contents`), and
43
+ * `commits/{sha}/check-runs` (`checks`). A job
44
+ * that omits `pull-requests: read` fails with 403 on a same-repository PR
45
+ * and skips every destroy matrix (#859). Give the discovery job this object.
46
+ */
47
+ export const PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS = Object.freeze({
48
+ checks: "read",
49
+ contents: "read",
50
+ "pull-requests": "read",
51
+ } as const);
52
+
38
53
  const INVENTORY_KIND = "patronage-factory-preview-proof";
39
54
  const INVENTORY_SCHEMA_VERSION = 1;
40
55
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -178,7 +193,7 @@ const readCleanup = (value: Record<string, unknown>) => {
178
193
  return null;
179
194
  }
180
195
  const evidence = readStringArray(value.cleanup.evidence);
181
- if (evidence === null || value.cleanupStatus !== value.cleanup.outcome) {
196
+ if (evidence === null) {
182
197
  return null;
183
198
  }
184
199
  return typeof value.cleanup.runUrl === "string" &&
@@ -197,10 +212,8 @@ const readPassingChecks = (value: Record<string, unknown>) => {
197
212
  }
198
213
  if (
199
214
  value.convergence.status !== "passed" ||
200
- value.convergenceStatus !== "passed" ||
201
215
  typeof value.convergence.detail !== "string" ||
202
216
  value.smoke.outcome !== "passed" ||
203
- value.smokeStatus !== "passed" ||
204
217
  typeof value.smoke.detail !== "string"
205
218
  ) {
206
219
  return null;
@@ -261,14 +274,11 @@ const readPreviewProofRegistration = (
261
274
  }
262
275
  return {
263
276
  cleanup,
264
- cleanupStatus: cleanup.outcome,
265
277
  convergence: passing.convergence,
266
- convergenceStatus: "passed",
267
278
  headSha: identity.headSha,
268
279
  pr: identity.pr,
269
280
  proof,
270
281
  smoke: passing.smoke,
271
- smokeStatus: "passed",
272
282
  source: "local-self-certified",
273
283
  stack: identity.stack,
274
284
  stage: identity.stage,
@@ -317,7 +327,8 @@ const replaceRegistration = (
317
327
  const memoryKey = (owner: string, repo: string, pr: number): string =>
318
328
  `${owner}/${repo}#${pr}`;
319
329
 
320
- export const memoryStore = (): PreviewProofInventoryStore => {
330
+ /** Reached by callers as `previewProofInventory.memoryStore`. */
331
+ const memoryStore = (): PreviewProofInventoryStore => {
321
332
  const byPr = new Map<string, PreviewProofRegistration[]>();
322
333
 
323
334
  return {
@@ -588,7 +599,7 @@ const checkRunOutput = (registration: PreviewProofRegistration) => {
588
599
  const title =
589
600
  `Preview proof ${registration.stack}/${registration.stage}`.slice(0, 255);
590
601
  return {
591
- summary: `cleanup ${registration.cleanupStatus}`,
602
+ summary: `cleanup ${registration.cleanup.outcome}`,
592
603
  text: JSON.stringify({
593
604
  kind: INVENTORY_KIND,
594
605
  registration,
@@ -967,7 +978,8 @@ const takeNewestRegistrations = (
967
978
  return [...byKey.values()];
968
979
  };
969
980
 
970
- export const githubStore = (
981
+ /** Reached by callers as `previewProofInventory.githubStore`. */
982
+ const githubStore = (
971
983
  transport: PreviewProofInventoryTransport
972
984
  ): PreviewProofInventoryStore => {
973
985
  const request = transport.fetch ?? fetch;
@@ -1172,6 +1184,7 @@ const recordCleanup = async (
1172
1184
  export const previewProofInventory = {
1173
1185
  githubStore,
1174
1186
  list,
1187
+ listPermissions: PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS,
1175
1188
  memoryStore,
1176
1189
  persist,
1177
1190
  recordCleanup,