@patronage/factory-ci 1.0.0-alpha.24 → 1.0.0-alpha.25

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 CHANGED
@@ -61,22 +61,50 @@ workflow({/* caller-owned jobs and topology */}).writeOrLint({
61
61
  });
62
62
  ```
63
63
 
64
- The **runner is not this package's business**. Jobs, runners, permissions, workflow topology, and deploy policy remain with the caller. The returned values are plain structural objects; this package does not depend on gagen.
65
-
66
- Candidate lifecycle support follows the same boundary. Use `FACTORY_CANDIDATE_PULL_REQUEST_TYPES` for the pull-request trigger matrix and `factoryCandidateOrPushCondition()` on each substantive job. With an optional caller-owned path condition, the helper emits the GitHub expression that runs merge-target pushes unconditionally and runs pull-request work only when GitHub's draft boolean says the pull request is a Candidate:
64
+ `factoryWorkflow()` still does not set `runsOn`. This package does not depend on gagen. Job ids, permissions, topology, and deploy policy stay with the caller. Verify runner kinds are this package's business. Name each job's kind (or an override with a non-empty why), pass `.label` into gagen `job()`, and hand the same table to `assertVerifyRunnerPolicy` next to `assertWorkflowShellParses`:
67
65
 
68
66
  ```ts
67
+ import {
68
+ assertVerifyRunnerPolicy,
69
+ assertWorkflowShellParses,
70
+ factoryVerifyRunner,
71
+ type FactoryVerifyRunner,
72
+ } from "@patronage/factory-ci";
73
+
74
+ type VerifyJobId = "changes" | "core";
75
+
76
+ const verifyRunners: Record<VerifyJobId, FactoryVerifyRunner> = {
77
+ changes: factoryVerifyRunner("rounding-tax"),
78
+ core: factoryVerifyRunner("sustained-compute"),
79
+ };
80
+
81
+ const runsOnFor = (id: VerifyJobId) => verifyRunners[id].label;
82
+
69
83
  const changes = job("changes", {
70
84
  if: factoryCandidateOrPushCondition(),
71
- // caller-owned runner, permissions, and steps
85
+ runsOn: runsOnFor("changes"),
72
86
  });
73
87
 
74
88
  const core = job("core", {
75
89
  if: factoryCandidateOrPushCondition("needs.changes.outputs.core == 'true'"),
76
- // caller-owned topology
90
+ runsOn: runsOnFor("core"),
91
+ });
92
+
93
+ const generated = workflow({ jobs: [changes, core] });
94
+
95
+ assertWorkflowShellParses(generated.toYamlString(), {
96
+ source: ".github/workflows/verify.ts",
97
+ });
98
+ assertVerifyRunnerPolicy(generated.toYamlString(), {
99
+ source: ".github/workflows/verify.ts",
100
+ jobs: verifyRunners,
77
101
  });
78
102
  ```
79
103
 
104
+ `sustained-compute` and `rounding-tax` both resolve to `depot-ubuntu-24.04`. They stay distinct so a short billed-rounding job cannot be confused with a long compute job in the table. `app-token` resolves to `ubuntu-latest`. `deploy-credentials` also resolves to `ubuntu-latest` and is refused inside `assertVerifyRunnerPolicy`, because a Verify workflow has no Cloudflare production secret. HQ deploy keeps a literal `ubuntu-latest` and never calls this assert.
105
+
106
+ Candidate lifecycle support follows the same boundary. Use `FACTORY_CANDIDATE_PULL_REQUEST_TYPES` for the pull-request trigger matrix and `factoryCandidateOrPushCondition()` on each substantive job. With an optional caller-owned path condition, the helper emits the GitHub expression that runs merge-target pushes unconditionally and runs pull-request work only when GitHub's draft boolean says the pull request is a Candidate. The runner label still comes from the kind table above.
107
+
80
108
  Migration: upgrade `@patronage/factory-ci`, apply the shared trigger types and job condition in the TypeScript workflow source, then regenerate and commit the emitted YAML. Draft `opened` and `synchronize` events will stop running substantive Verify work. Promotion through `ready_for_review`, later non-draft Candidate events, and merge-target pushes continue to run their applicable battery. A skipped draft run is presentation, not proof.
81
109
 
82
110
  Production impact support follows the same ownership line. A generated deploy workflow declares only its target names and consumes the returned decision step and fail-open `demandedIf(target, decisionJob)` expressions:
@@ -257,7 +285,10 @@ The audit job is `always()` so a failed destroy still runs the stage-wide check.
257
285
  ### Merge-freeze writer job
258
286
 
259
287
  ```ts
260
- import { factoryMergeFreezeJob } from "@patronage/factory-ci";
288
+ import {
289
+ factoryMergeFreezeJob,
290
+ factoryVerifyRunner,
291
+ } from "@patronage/factory-ci";
261
292
 
262
293
  const freeze = factoryMergeFreezeJob({
263
294
  createGithubAppToken: workflowArtifact.actions.createGithubAppToken,
@@ -272,7 +303,7 @@ const jobs = [
272
303
  name: freeze.jobName,
273
304
  needs: freeze.needs,
274
305
  if: freeze.if,
275
- runsOn: "ubuntu-latest",
306
+ runsOn: factoryVerifyRunner("app-token").label,
276
307
  permissions: freeze.permissions,
277
308
  timeoutMinutes: 10,
278
309
  steps: freeze.steps,
@@ -282,7 +313,7 @@ const jobs = [
282
313
 
283
314
  A consumer's generated merge-target-push Verify workflow is the only producer of `patronage-factory/merge-freeze` generations (#356, ADR 0016 as amended; #429, #872). A red merge-target Verify completes a generation active, a green one completes it inactive, and `pr:ready` reads the generation on the candidate's own base tip. No command, scheduled job, or second workflow writes that check run; the operator override (`demand:waive --demand merge-freeze`) waives the demand for one candidate and never writes here.
284
315
 
285
- `factoryMergeFreezeJob` owns everything a reader trusts: the check name (`FACTORY_MERGE_FREEZE_CHECK_NAME`), the pinned Patronage Factory App identity, the `actions/create-github-app-token` inputs, the `needs.*.result` fold (`FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION`), the merge-target condition (`FACTORY_MERGE_FREEZE_IF`, built from `FACTORY_MERGE_TARGET_REF_CONDITION`), and the job's least-privilege `permissions` (an empty block: the App installation token carries the check-run write, the job uses no `GITHUB_TOKEN` scope, and it never checks out the repository). The caller owns the runner, the timeout, and the `needs` list.
316
+ `factoryMergeFreezeJob` owns everything a reader trusts: the check name (`FACTORY_MERGE_FREEZE_CHECK_NAME`), the pinned Patronage Factory App identity, the `actions/create-github-app-token` inputs, the `needs.*.result` fold (`FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION`), the merge-target condition (`FACTORY_MERGE_FREEZE_IF`, built from `FACTORY_MERGE_TARGET_REF_CONDITION`), and the job's least-privilege `permissions` (an empty block: the App installation token carries the check-run write, the job uses no `GITHUB_TOKEN` scope, and it never checks out the repository). The caller owns the runner kind (`app-token` for the Factory App token), the timeout, and the `needs` list.
286
317
 
287
318
  That `needs` list is the whole trust input: it must name **every authoritative verification leaf**, because the fold is what decides active or inactive. An empty list is refused — a fold over no needed job always reports success, which would report a merge target nothing verified as green.
288
319
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "fingerprint": "43a41eecd333f5075b92a78d7d29e9d5e6b723acea5c6bb063e5bb952bf5bf9d"
3
+ }
package/dist/index.d.ts CHANGED
@@ -1577,6 +1577,53 @@ type VitestProfileReadResult = {
1577
1577
  */
1578
1578
  declare const readVitestProfileDocument: (value: unknown) => VitestProfileReadResult;
1579
1579
  //#endregion
1580
+ //#region src/verify-runner-policy.d.ts
1581
+ declare const DEPOT_LABEL = "depot-ubuntu-24.04";
1582
+ declare const HOSTED_LABEL = "ubuntu-latest";
1583
+ /**
1584
+ * Why a job runs where it does. The two Depot kinds share a label. They stay
1585
+ * distinct so billed-rounding jobs and long compute jobs cannot be confused
1586
+ * in the declaration table.
1587
+ *
1588
+ * `deploy-credentials` is legal to construct (HQ deploy).
1589
+ * `assertVerifyRunnerPolicy` refuses it, because a Verify workflow has no
1590
+ * Cloudflare production secret.
1591
+ */
1592
+ type FactoryRunnerKind = "sustained-compute" | "rounding-tax" | "app-token" | "deploy-credentials";
1593
+ type FactoryRunnerHome = "depot" | "hosted";
1594
+ interface FactoryRunnerOverride {
1595
+ readonly kind: "override";
1596
+ readonly home: FactoryRunnerHome;
1597
+ readonly why: string;
1598
+ }
1599
+ type FactoryRunnerDecision = FactoryRunnerKind | FactoryRunnerOverride;
1600
+ interface FactoryVerifyRunner {
1601
+ readonly decision: FactoryRunnerDecision;
1602
+ readonly home: FactoryRunnerHome;
1603
+ readonly label: typeof DEPOT_LABEL | typeof HOSTED_LABEL;
1604
+ readonly why?: string;
1605
+ }
1606
+ type FactoryVerifyRunnerTable = Readonly<Record<string, FactoryVerifyRunner>>;
1607
+ interface FactoryVerifyRunnerPolicyInput {
1608
+ readonly source: string;
1609
+ readonly jobs: FactoryVerifyRunnerTable;
1610
+ }
1611
+ interface FactoryVerifyRunnerMismatch {
1612
+ readonly jobId: string;
1613
+ readonly expected: string;
1614
+ readonly actual: string;
1615
+ }
1616
+ interface FactoryVerifyRunnerPolicyReport {
1617
+ readonly ok: boolean;
1618
+ readonly undeclared: readonly string[];
1619
+ readonly missing: readonly string[];
1620
+ readonly mismatches: readonly FactoryVerifyRunnerMismatch[];
1621
+ readonly forbiddenKinds: readonly string[];
1622
+ }
1623
+ declare const factoryVerifyRunner: (decision: FactoryRunnerDecision) => FactoryVerifyRunner;
1624
+ declare const verifyRunnerPolicy: (yaml: string, input: FactoryVerifyRunnerPolicyInput) => FactoryVerifyRunnerPolicyReport;
1625
+ declare const assertVerifyRunnerPolicy: (yaml: string, input: FactoryVerifyRunnerPolicyInput) => FactoryVerifyRunnerPolicyReport;
1626
+ //#endregion
1580
1627
  //#region src/workflow-shell-lint.d.ts
1581
1628
  /**
1582
1629
  * Parse-check the shell embedded in generated workflow YAML (#376).
@@ -1628,4 +1675,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
1628
1675
  readonly source: string;
1629
1676
  }) => void;
1630
1677
  //#endregion
1631
- export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, 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_PULL_REQUEST_IF, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, type FactoryCandidateImpactWorkflow, type FactoryCandidateImpactWorkflowOptions, type FactoryLifecycleContractLane, type FactoryLifecycleContractLaneOptions, type FactoryMergeFreezeJob, type FactoryMergeFreezeJobOptions, type FactoryPrStatusHudWorkflow, type FactoryPrStatusHudWorkflowOptions, type FactoryPreviewCleanupAuditJob, type FactoryPreviewCleanupDestroyJob, type FactoryPreviewCleanupTopology, type FactoryPreviewCleanupTopologyOptions, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateReuse, type FactoryProofGateStep, type FactoryProofReusePresentationOptions, type FactoryProofReuseSummaryStep, type FactoryProofTimingStartStep, type FactoryPushIdentityConsumer, type FactoryPushIdentityConsumerOptions, type FactoryPushIdentityDisposition, type FactoryPushIdentityEnvelope, type FactoryPushIdentityProducer, type FactoryPushIdentityProducerOptions, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, GitHubAppTokenTransportError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type PreviewProofCandidate, type PreviewProofCleanupOutcome, type PreviewProofCleanupStatus, type PreviewProofEnvelopeEvidence, type PreviewProofInventoryAccess, type PreviewProofInventoryCleanupInput, type PreviewProofInventoryListInput, type PreviewProofInventoryPersistInput, type PreviewProofInventoryStore, type PreviewProofInventoryTransport, type PreviewProofLifecycleEvent, type PreviewProofLifecycleState, type PreviewProofRegistration, type PreviewProofResolveQuery, PreviewProofTransportError, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, UPLOAD_ARTIFACT, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileReadResult, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
1678
+ export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, 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_PULL_REQUEST_IF, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, type FactoryCandidateImpactWorkflow, type FactoryCandidateImpactWorkflowOptions, type FactoryLifecycleContractLane, type FactoryLifecycleContractLaneOptions, type FactoryMergeFreezeJob, type FactoryMergeFreezeJobOptions, type FactoryPrStatusHudWorkflow, type FactoryPrStatusHudWorkflowOptions, type FactoryPreviewCleanupAuditJob, type FactoryPreviewCleanupDestroyJob, type FactoryPreviewCleanupTopology, type FactoryPreviewCleanupTopologyOptions, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateReuse, type FactoryProofGateStep, type FactoryProofReusePresentationOptions, type FactoryProofReuseSummaryStep, type FactoryProofTimingStartStep, type FactoryPushIdentityConsumer, type FactoryPushIdentityConsumerOptions, type FactoryPushIdentityDisposition, type FactoryPushIdentityEnvelope, type FactoryPushIdentityProducer, type FactoryPushIdentityProducerOptions, type FactoryRunnerDecision, type FactoryRunnerHome, type FactoryRunnerKind, type FactoryRunnerOverride, type FactoryVerifyRunner, type FactoryVerifyRunnerMismatch, type FactoryVerifyRunnerPolicyInput, type FactoryVerifyRunnerPolicyReport, type FactoryVerifyRunnerTable, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, GitHubAppTokenTransportError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type PreviewProofCandidate, type PreviewProofCleanupOutcome, type PreviewProofCleanupStatus, type PreviewProofEnvelopeEvidence, type PreviewProofInventoryAccess, type PreviewProofInventoryCleanupInput, type PreviewProofInventoryListInput, type PreviewProofInventoryPersistInput, type PreviewProofInventoryStore, type PreviewProofInventoryTransport, type PreviewProofLifecycleEvent, type PreviewProofLifecycleState, type PreviewProofRegistration, type PreviewProofResolveQuery, PreviewProofTransportError, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, UPLOAD_ARTIFACT, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileReadResult, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertVerifyRunnerPolicy, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryVerifyRunner, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, verifyRunnerPolicy, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/dist/index.js CHANGED
@@ -2006,7 +2006,7 @@ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ] && [ -z "${shellExpan
2006
2006
  # the PR-only reuse default (#863) that is every push. A consumer on
2007
2007
  # 'pull-request-and-default-branch' reaches it only on a ref outside its
2008
2008
  # merge target.
2009
- say '- Path: merge-target full execution'
2009
+ say '- Path: full execution, no proof consulted'
2010
2010
  say '- Proof-reuse gate: did not run on this ref; the full suite ran.'
2011
2011
  say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
2012
2012
  echo "::notice title=Factory proof reuse::The proof-reuse gate did not run on this ref; the full ${label} suite executed."
@@ -3342,6 +3342,123 @@ const readVitestProfileDocument = (value) => {
3342
3342
  };
3343
3343
  };
3344
3344
  //#endregion
3345
+ //#region src/verify-runner-policy.ts
3346
+ const DEPOT_LABEL = "depot-ubuntu-24.04";
3347
+ const HOSTED_LABEL = "ubuntu-latest";
3348
+ const isOverride = (decision) => typeof decision === "object";
3349
+ const homeForKind = (kind) => {
3350
+ switch (kind) {
3351
+ case "sustained-compute":
3352
+ case "rounding-tax": return "depot";
3353
+ case "app-token":
3354
+ case "deploy-credentials": return "hosted";
3355
+ default: return kind;
3356
+ }
3357
+ };
3358
+ const labelForHome = (home) => home === "depot" ? DEPOT_LABEL : HOSTED_LABEL;
3359
+ const assertOverrideWhy = (why) => {
3360
+ if (why.includes("\n") || why.includes("\r") || why.trim().length === 0) throw new Error("factoryVerifyRunner override requires a non-empty single-line why");
3361
+ };
3362
+ const factoryVerifyRunner = (decision) => {
3363
+ if (isOverride(decision)) {
3364
+ assertOverrideWhy(decision.why);
3365
+ return Object.freeze({
3366
+ decision,
3367
+ home: decision.home,
3368
+ label: labelForHome(decision.home),
3369
+ why: decision.why
3370
+ });
3371
+ }
3372
+ const home = homeForKind(decision);
3373
+ return Object.freeze({
3374
+ decision,
3375
+ home,
3376
+ label: labelForHome(home)
3377
+ });
3378
+ };
3379
+ const indentOf$1 = (line) => line.length - line.trimStart().length;
3380
+ const unquote$1 = (raw) => {
3381
+ const value = raw.trim();
3382
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) return value.slice(1, -1).replaceAll("''", "'");
3383
+ if (value.startsWith("\"") && value.endsWith("\"") && value.length > 1) return value.slice(1, -1);
3384
+ return value;
3385
+ };
3386
+ const JOB_ID = /^[\w-]+$/u;
3387
+ const MATRIX_EXPRESSION = /\$\{\{\s*matrix\b/u;
3388
+ const throwNonScalarRunsOn = (jobId) => {
3389
+ throw new Error(`job ${jobId} uses a list, group, or matrix runs-on; Verify policy accepts a scalar label only`);
3390
+ };
3391
+ const verifyWorkflowJobRunners = (yaml) => {
3392
+ const lines = yaml.split("\n");
3393
+ const jobsLine = lines.findIndex((line) => line.trim() === "jobs:");
3394
+ if (jobsLine === -1) return {};
3395
+ const jobsIndent = indentOf$1(lines[jobsLine]);
3396
+ const jobIdIndent = jobsIndent + 2;
3397
+ const jobKeyIndent = jobIdIndent + 2;
3398
+ const runners = {};
3399
+ for (let index = jobsLine + 1; index < lines.length; index += 1) {
3400
+ const line = lines[index];
3401
+ if (line.trim().length === 0) continue;
3402
+ const indent = indentOf$1(line);
3403
+ if (indent <= jobsIndent) break;
3404
+ if (indent !== jobIdIndent) continue;
3405
+ const trimmed = line.trim();
3406
+ if (!trimmed.endsWith(":") || trimmed.includes(" ")) continue;
3407
+ const jobId = trimmed.slice(0, -1);
3408
+ if (!JOB_ID.test(jobId)) continue;
3409
+ let runsOn;
3410
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
3411
+ const candidate = lines[cursor];
3412
+ if (candidate.trim().length === 0) continue;
3413
+ const candidateIndent = indentOf$1(candidate);
3414
+ if (candidateIndent <= jobIdIndent) break;
3415
+ if (candidateIndent !== jobKeyIndent) continue;
3416
+ const rest = candidate.trimStart();
3417
+ if (!rest.startsWith("runs-on:")) continue;
3418
+ const inline = rest.slice(8).trim();
3419
+ if (inline.length === 0) throwNonScalarRunsOn(jobId);
3420
+ if (inline.startsWith("[") || inline.startsWith("{")) throwNonScalarRunsOn(jobId);
3421
+ const label = unquote$1(inline);
3422
+ if (MATRIX_EXPRESSION.test(label)) throwNonScalarRunsOn(jobId);
3423
+ runsOn = label;
3424
+ break;
3425
+ }
3426
+ if (runsOn === void 0) throw new Error(`job ${jobId} has no runs-on`);
3427
+ runners[jobId] = runsOn;
3428
+ }
3429
+ return runners;
3430
+ };
3431
+ const isDeployCredentials = (runner) => runner.decision === "deploy-credentials";
3432
+ const verifyRunnerPolicy = (yaml, input) => {
3433
+ const actual = verifyWorkflowJobRunners(yaml);
3434
+ const declared = Object.keys(input.jobs);
3435
+ const undeclared = Object.keys(actual).filter((jobId) => input.jobs[jobId] === void 0).toSorted();
3436
+ const missing = declared.filter((jobId) => actual[jobId] === void 0).toSorted();
3437
+ const mismatches = declared.filter((jobId) => actual[jobId] !== void 0 && actual[jobId] !== input.jobs[jobId].label).toSorted().map((jobId) => ({
3438
+ actual: actual[jobId],
3439
+ expected: input.jobs[jobId].label,
3440
+ jobId
3441
+ }));
3442
+ const forbiddenKinds = declared.filter((jobId) => isDeployCredentials(input.jobs[jobId])).toSorted();
3443
+ return Object.freeze({
3444
+ forbiddenKinds,
3445
+ mismatches,
3446
+ missing,
3447
+ ok: undeclared.length === 0 && missing.length === 0 && mismatches.length === 0 && forbiddenKinds.length === 0,
3448
+ undeclared
3449
+ });
3450
+ };
3451
+ const assertVerifyRunnerPolicy = (yaml, input) => {
3452
+ const report = verifyRunnerPolicy(yaml, input);
3453
+ if (report.ok) return report;
3454
+ const details = [];
3455
+ if (report.undeclared.length > 0) details.push(`undeclared jobs: ${report.undeclared.join(", ")}`);
3456
+ if (report.missing.length > 0) details.push(`missing from YAML: ${report.missing.join(", ")}`);
3457
+ for (const mismatch of report.mismatches) details.push(`${mismatch.jobId} runs-on ${mismatch.actual}, expected ${mismatch.expected}`);
3458
+ if (report.forbiddenKinds.length > 0) details.push(`deploy-credentials is illegal inside Verify: ${report.forbiddenKinds.join(", ")}`);
3459
+ throw new Error(`${input.source} Verify runner policy failed:\n${details.map((detail) => ` - ${detail}`).join("\n")}`);
3460
+ };
3461
+ //#endregion
3345
3462
  //#region src/workflow-shell-lint.ts
3346
3463
  const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
3347
3464
  /**
@@ -3606,4 +3723,4 @@ const assertWorkflowShellParses = (yaml, options) => {
3606
3723
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
3607
3724
  };
3608
3725
  //#endregion
3609
- export { EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, 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_PULL_REQUEST_IF, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, GitHubAppTokenTransportError, NODE_PNPM_ACTION_FAMILY_NODE24, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, PreviewProofTransportError, UPLOAD_ARTIFACT, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
3726
+ export { EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, 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_PULL_REQUEST_IF, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, GitHubAppTokenTransportError, NODE_PNPM_ACTION_FAMILY_NODE24, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, PreviewProofTransportError, UPLOAD_ARTIFACT, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertVerifyRunnerPolicy, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryVerifyRunner, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, verifyRunnerPolicy, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "1.0.0-alpha.24",
3
+ "version": "1.0.0-alpha.25",
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",
@@ -50,7 +50,7 @@
50
50
  "scripts": {
51
51
  "cleanup:check": "knip --files --dependencies --exports",
52
52
  "prebuild": "bash ../scripts/ensure-worktree-bootstrap.sh",
53
- "build": "tsdown",
53
+ "build": "tsdown && node ../scripts/factory-ci-dist-stamp.mjs write",
54
54
  "precheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
55
55
  "check": "ultracite check",
56
56
  "prefix": "bash ../scripts/ensure-worktree-bootstrap.sh",
package/src/index.ts CHANGED
@@ -209,6 +209,20 @@ export {
209
209
  readVitestProfileDocument,
210
210
  type VitestProfileReadResult,
211
211
  } from "./vitest-profile-reader.ts";
212
+ export {
213
+ assertVerifyRunnerPolicy,
214
+ factoryVerifyRunner,
215
+ type FactoryRunnerDecision,
216
+ type FactoryRunnerHome,
217
+ type FactoryRunnerKind,
218
+ type FactoryRunnerOverride,
219
+ type FactoryVerifyRunner,
220
+ type FactoryVerifyRunnerMismatch,
221
+ type FactoryVerifyRunnerPolicyInput,
222
+ type FactoryVerifyRunnerPolicyReport,
223
+ type FactoryVerifyRunnerTable,
224
+ verifyRunnerPolicy,
225
+ } from "./verify-runner-policy.ts";
212
226
  export {
213
227
  assertWorkflowShellParses,
214
228
  workflowRunBlocks,
@@ -65,7 +65,7 @@ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ] && [ -z "${shellExpan
65
65
  # the PR-only reuse default (#863) that is every push. A consumer on
66
66
  # 'pull-request-and-default-branch' reaches it only on a ref outside its
67
67
  # merge target.
68
- say '- Path: merge-target full execution'
68
+ say '- Path: full execution, no proof consulted'
69
69
  say '- Proof-reuse gate: did not run on this ref; the full suite ran.'
70
70
  say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
71
71
  echo "::notice title=Factory proof reuse::The proof-reuse gate did not run on this ref; the full ${label} suite executed."
@@ -0,0 +1,290 @@
1
+ const DEPOT_LABEL = "depot-ubuntu-24.04";
2
+ const HOSTED_LABEL = "ubuntu-latest";
3
+
4
+ /**
5
+ * Why a job runs where it does. The two Depot kinds share a label. They stay
6
+ * distinct so billed-rounding jobs and long compute jobs cannot be confused
7
+ * in the declaration table.
8
+ *
9
+ * `deploy-credentials` is legal to construct (HQ deploy).
10
+ * `assertVerifyRunnerPolicy` refuses it, because a Verify workflow has no
11
+ * Cloudflare production secret.
12
+ */
13
+ export type FactoryRunnerKind =
14
+ | "sustained-compute"
15
+ | "rounding-tax"
16
+ | "app-token"
17
+ | "deploy-credentials";
18
+
19
+ export type FactoryRunnerHome = "depot" | "hosted";
20
+
21
+ export interface FactoryRunnerOverride {
22
+ readonly kind: "override";
23
+ readonly home: FactoryRunnerHome;
24
+ readonly why: string;
25
+ }
26
+
27
+ export type FactoryRunnerDecision = FactoryRunnerKind | FactoryRunnerOverride;
28
+
29
+ export interface FactoryVerifyRunner {
30
+ readonly decision: FactoryRunnerDecision;
31
+ readonly home: FactoryRunnerHome;
32
+ readonly label: typeof DEPOT_LABEL | typeof HOSTED_LABEL;
33
+ readonly why?: string;
34
+ }
35
+
36
+ export type FactoryVerifyRunnerTable = Readonly<
37
+ Record<string, FactoryVerifyRunner>
38
+ >;
39
+
40
+ export interface FactoryVerifyRunnerPolicyInput {
41
+ readonly source: string;
42
+ readonly jobs: FactoryVerifyRunnerTable;
43
+ }
44
+
45
+ export interface FactoryVerifyRunnerMismatch {
46
+ readonly jobId: string;
47
+ readonly expected: string;
48
+ readonly actual: string;
49
+ }
50
+
51
+ export interface FactoryVerifyRunnerPolicyReport {
52
+ readonly ok: boolean;
53
+ readonly undeclared: readonly string[];
54
+ readonly missing: readonly string[];
55
+ readonly mismatches: readonly FactoryVerifyRunnerMismatch[];
56
+ readonly forbiddenKinds: readonly string[];
57
+ }
58
+
59
+ const isOverride = (
60
+ decision: FactoryRunnerDecision
61
+ ): decision is FactoryRunnerOverride => typeof decision === "object";
62
+
63
+ const homeForKind = (kind: FactoryRunnerKind): FactoryRunnerHome => {
64
+ switch (kind) {
65
+ case "sustained-compute":
66
+ case "rounding-tax": {
67
+ return "depot";
68
+ }
69
+ case "app-token":
70
+ case "deploy-credentials": {
71
+ return "hosted";
72
+ }
73
+ default: {
74
+ const exhaustive: never = kind;
75
+ return exhaustive;
76
+ }
77
+ }
78
+ };
79
+
80
+ const labelForHome = (
81
+ home: FactoryRunnerHome
82
+ ): typeof DEPOT_LABEL | typeof HOSTED_LABEL =>
83
+ home === "depot" ? DEPOT_LABEL : HOSTED_LABEL;
84
+
85
+ const assertOverrideWhy = (why: string): void => {
86
+ if (why.includes("\n") || why.includes("\r") || why.trim().length === 0) {
87
+ throw new Error(
88
+ "factoryVerifyRunner override requires a non-empty single-line why"
89
+ );
90
+ }
91
+ };
92
+
93
+ export const factoryVerifyRunner = (
94
+ decision: FactoryRunnerDecision
95
+ ): FactoryVerifyRunner => {
96
+ if (isOverride(decision)) {
97
+ assertOverrideWhy(decision.why);
98
+ return Object.freeze({
99
+ decision,
100
+ home: decision.home,
101
+ label: labelForHome(decision.home),
102
+ why: decision.why,
103
+ });
104
+ }
105
+
106
+ const home = homeForKind(decision);
107
+ return Object.freeze({
108
+ decision,
109
+ home,
110
+ label: labelForHome(home),
111
+ });
112
+ };
113
+
114
+ const indentOf = (line: string): number =>
115
+ line.length - line.trimStart().length;
116
+
117
+ const unquote = (raw: string): string => {
118
+ const value = raw.trim();
119
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) {
120
+ return value.slice(1, -1).replaceAll("''", "'");
121
+ }
122
+ if (value.startsWith('"') && value.endsWith('"') && value.length > 1) {
123
+ return value.slice(1, -1);
124
+ }
125
+ return value;
126
+ };
127
+
128
+ const JOB_ID = /^[\w-]+$/u;
129
+ const MATRIX_EXPRESSION = /\$\{\{\s*matrix\b/u;
130
+
131
+ const throwNonScalarRunsOn = (jobId: string): never => {
132
+ throw new Error(
133
+ `job ${jobId} uses a list, group, or matrix runs-on; Verify policy accepts a scalar label only`
134
+ );
135
+ };
136
+
137
+ const verifyWorkflowJobRunners = (
138
+ yaml: string
139
+ ): Readonly<Record<string, string>> => {
140
+ const lines = yaml.split("\n");
141
+ const jobsLine = lines.findIndex((line) => line.trim() === "jobs:");
142
+ if (jobsLine === -1) {
143
+ return {};
144
+ }
145
+
146
+ const jobsIndent = indentOf(lines[jobsLine]);
147
+ const jobIdIndent = jobsIndent + 2;
148
+ const jobKeyIndent = jobIdIndent + 2;
149
+ const runners: Record<string, string> = {};
150
+
151
+ for (let index = jobsLine + 1; index < lines.length; index += 1) {
152
+ const line = lines[index];
153
+ if (line.trim().length === 0) {
154
+ continue;
155
+ }
156
+ const indent = indentOf(line);
157
+ if (indent <= jobsIndent) {
158
+ break;
159
+ }
160
+ if (indent !== jobIdIndent) {
161
+ continue;
162
+ }
163
+
164
+ const trimmed = line.trim();
165
+ if (!trimmed.endsWith(":") || trimmed.includes(" ")) {
166
+ continue;
167
+ }
168
+ const jobId = trimmed.slice(0, -1);
169
+ if (!JOB_ID.test(jobId)) {
170
+ continue;
171
+ }
172
+
173
+ let runsOn: string | undefined;
174
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
175
+ const candidate = lines[cursor];
176
+ if (candidate.trim().length === 0) {
177
+ continue;
178
+ }
179
+ const candidateIndent = indentOf(candidate);
180
+ if (candidateIndent <= jobIdIndent) {
181
+ break;
182
+ }
183
+ if (candidateIndent !== jobKeyIndent) {
184
+ continue;
185
+ }
186
+ const rest = candidate.trimStart();
187
+ if (!rest.startsWith("runs-on:")) {
188
+ continue;
189
+ }
190
+ const inline = rest.slice("runs-on:".length).trim();
191
+ if (inline.length === 0) {
192
+ throwNonScalarRunsOn(jobId);
193
+ }
194
+ if (inline.startsWith("[") || inline.startsWith("{")) {
195
+ throwNonScalarRunsOn(jobId);
196
+ }
197
+ const label = unquote(inline);
198
+ if (MATRIX_EXPRESSION.test(label)) {
199
+ throwNonScalarRunsOn(jobId);
200
+ }
201
+ runsOn = label;
202
+ break;
203
+ }
204
+ if (runsOn === undefined) {
205
+ throw new Error(`job ${jobId} has no runs-on`);
206
+ }
207
+ runners[jobId] = runsOn;
208
+ }
209
+
210
+ return runners;
211
+ };
212
+
213
+ const isDeployCredentials = (runner: FactoryVerifyRunner): boolean =>
214
+ runner.decision === "deploy-credentials";
215
+
216
+ export const verifyRunnerPolicy = (
217
+ yaml: string,
218
+ input: FactoryVerifyRunnerPolicyInput
219
+ ): FactoryVerifyRunnerPolicyReport => {
220
+ const actual = verifyWorkflowJobRunners(yaml);
221
+ const declared = Object.keys(input.jobs);
222
+ const emitted = Object.keys(actual);
223
+
224
+ const undeclared = emitted
225
+ .filter((jobId) => input.jobs[jobId] === undefined)
226
+ .toSorted();
227
+ const missing = declared
228
+ .filter((jobId) => actual[jobId] === undefined)
229
+ .toSorted();
230
+ const mismatches = declared
231
+ .filter(
232
+ (jobId) =>
233
+ actual[jobId] !== undefined && actual[jobId] !== input.jobs[jobId].label
234
+ )
235
+ .toSorted()
236
+ .map((jobId) => ({
237
+ actual: actual[jobId],
238
+ expected: input.jobs[jobId].label,
239
+ jobId,
240
+ }));
241
+ const forbiddenKinds = declared
242
+ .filter((jobId) => isDeployCredentials(input.jobs[jobId]))
243
+ .toSorted();
244
+
245
+ return Object.freeze({
246
+ forbiddenKinds,
247
+ mismatches,
248
+ missing,
249
+ ok:
250
+ undeclared.length === 0 &&
251
+ missing.length === 0 &&
252
+ mismatches.length === 0 &&
253
+ forbiddenKinds.length === 0,
254
+ undeclared,
255
+ });
256
+ };
257
+
258
+ export const assertVerifyRunnerPolicy = (
259
+ yaml: string,
260
+ input: FactoryVerifyRunnerPolicyInput
261
+ ): FactoryVerifyRunnerPolicyReport => {
262
+ const report = verifyRunnerPolicy(yaml, input);
263
+ if (report.ok) {
264
+ return report;
265
+ }
266
+
267
+ const details: string[] = [];
268
+ if (report.undeclared.length > 0) {
269
+ details.push(`undeclared jobs: ${report.undeclared.join(", ")}`);
270
+ }
271
+ if (report.missing.length > 0) {
272
+ details.push(`missing from YAML: ${report.missing.join(", ")}`);
273
+ }
274
+ for (const mismatch of report.mismatches) {
275
+ details.push(
276
+ `${mismatch.jobId} runs-on ${mismatch.actual}, expected ${mismatch.expected}`
277
+ );
278
+ }
279
+ if (report.forbiddenKinds.length > 0) {
280
+ details.push(
281
+ `deploy-credentials is illegal inside Verify: ${report.forbiddenKinds.join(", ")}`
282
+ );
283
+ }
284
+
285
+ throw new Error(
286
+ `${input.source} Verify runner policy failed:\n${details
287
+ .map((detail) => ` - ${detail}`)
288
+ .join("\n")}`
289
+ );
290
+ };