@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/README.md +46 -10
- package/dist/index.d.ts +253 -27
- package/dist/index.js +374 -124
- package/package.json +2 -2
- package/src/actions.ts +13 -0
- package/src/candidate-impact-workflow.ts +17 -14
- package/src/execute-alchemy-entry.ts +27 -22
- package/src/impact-demand-condition.ts +67 -0
- package/src/index.ts +18 -0
- package/src/merge-freeze-job.ts +237 -0
- package/src/pr-status-hud-workflow.ts +6 -8
- package/src/preview-proof-inventory.ts +22 -9
- package/src/preview-proof-lifecycle.ts +1 -8
- package/src/production-impact-workflow.ts +17 -14
- package/src/proof-reuse-gate.ts +152 -83
- package/src/push-identity-workflow.ts +27 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/factory-ci",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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
|
-
/**
|
|
27
|
-
|
|
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:
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
return
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 =
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|