@patronage/factory-ci 0.2.0 → 1.0.0-alpha.10

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
@@ -20,7 +20,10 @@ The admission rule is **upstream on repetition**: nothing enters this package un
20
20
 
21
21
  ```ts
22
22
  import {
23
+ FACTORY_CANDIDATE_PULL_REQUEST_TYPES,
24
+ factoryProductionImpactWorkflow,
23
25
  factoryWorkflow,
26
+ factoryCandidateOrPushCondition,
24
27
  NODE_PNPM_ACTION_FAMILY_NODE24,
25
28
  } from "@patronage/factory-ci";
26
29
  ```
@@ -47,6 +50,7 @@ const generated = factoryWorkflow({
47
50
  regenerate: "pnpm workflows:generate",
48
51
  },
49
52
  setup: {
53
+ checkout: { fetchDepth: 0, ref: "${{ github.sha }}" },
50
54
  setupNode: { cacheDependencyPath: "pnpm-lock.yaml" },
51
55
  },
52
56
  });
@@ -59,6 +63,132 @@ workflow({/* caller-owned jobs and topology */}).writeOrLint({
59
63
 
60
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.
61
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:
67
+
68
+ ```ts
69
+ const changes = job("changes", {
70
+ if: factoryCandidateOrPushCondition(),
71
+ // caller-owned runner, permissions, and steps
72
+ });
73
+
74
+ const core = job("core", {
75
+ if: factoryCandidateOrPushCondition("needs.changes.outputs.core == 'true'"),
76
+ // caller-owned topology
77
+ });
78
+ ```
79
+
80
+ 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
+
82
+ 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)` expressions:
83
+
84
+ ```ts
85
+ const impact = factoryProductionImpactWorkflow({
86
+ targets: profile.impact?.targets.map(({ name }) => name) ?? [],
87
+ });
88
+
89
+ const decide = job("impact", {
90
+ outputs: impact.decisionJobOutputs,
91
+ steps: [...setupSteps, impact.decisionStep],
92
+ });
93
+
94
+ const deployWebsite = job("deploy-website", {
95
+ if: impact.demandedIf("website", "impact"),
96
+ needs: [decide],
97
+ // consumer-owned runner, credentials, deploy commands, and convergence
98
+ });
99
+ ```
100
+
101
+ The step calls `psf production:impact` with the merge push's exact `before` and `after` commits. It is `continue-on-error`, and every generated target condition keeps work demanded unless the command succeeded, reported a usable decision, and explicitly withdrew that target. The artifact with an empty target list generates no deploy jobs because job creation remains with the consumer. This package does not own target declarations, deploy topology, credentials, commands, or convergence/no-op proof.
102
+
103
+ The decision checkout must make both push identities reachable. Use `factoryWorkflow({ setup: { checkout: { fetchDepth: 0, ref: "${{ github.sha }}" } } })`; a shallow checkout is safe but deliberately refuses withdrawal because the `before` commit is unreadable.
104
+
105
+ When deployment begins from a successful `workflow_run` instead of the push event itself, carry that push identity through the typed artifact seam rather than reconstructing `before` from `HEAD^`:
106
+
107
+ ```ts
108
+ import {
109
+ factoryProductionImpactWorkflow,
110
+ factoryPushIdentityConsumer,
111
+ factoryPushIdentityProducer,
112
+ factoryWorkflow,
113
+ NODE_PNPM_ACTION_FAMILY_NODE24,
114
+ } from "@patronage/factory-ci";
115
+
116
+ const workflowArtifact = factoryWorkflow({
117
+ actionFamily: NODE_PNPM_ACTION_FAMILY_NODE24,
118
+ additionalActions: {
119
+ downloadArtifact: {
120
+ tag: "v5.0.0",
121
+ uses: "actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0",
122
+ },
123
+ uploadArtifact: {
124
+ tag: "v4.6.2",
125
+ uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
126
+ },
127
+ },
128
+ generated: {
129
+ source: ".github/workflows/verify.ts",
130
+ regenerate: "pnpm workflows:generate",
131
+ },
132
+ });
133
+
134
+ // At the end of the push-triggered Verify job:
135
+ const producer = factoryPushIdentityProducer({
136
+ if: "github.ref == 'refs/heads/main'",
137
+ uploadArtifact: workflowArtifact.actions.uploadArtifact,
138
+ });
139
+ const verifySteps = [...consumerOwnedVerifySteps, ...producer.steps];
140
+
141
+ // In a caller-owned workflow_run decision job:
142
+ const identity = factoryPushIdentityConsumer({
143
+ checkout: workflowArtifact.actions.checkout,
144
+ downloadArtifact: workflowArtifact.actions.downloadArtifact,
145
+ });
146
+ const impact = factoryProductionImpactWorkflow({
147
+ after: identity.outputs.after,
148
+ before: identity.outputs.before,
149
+ targets: profile.impact.targets.map(({ name }) => name),
150
+ });
151
+ const decision = {
152
+ outputs: { ...identity.outputs, ...impact.decisionJobOutputs },
153
+ permissions: identity.requiredPermissions,
154
+ steps: [
155
+ ...identity.steps,
156
+ ...consumerOwnedInstallSteps,
157
+ { ...impact.decisionStep, if: identity.usableIf },
158
+ ],
159
+ };
160
+ ```
161
+
162
+ The producer records schema version, repository, string run ID, and the exact push-event `before` / `after` values, then uploads one run-named artifact. The consumer enumerates artifacts only on the triggering run in the current repository, requires that run to have been triggered by `push` and concluded successfully, requires exactly one bounded artifact, downloads it by artifact ID, checks out the triggering head with full history, and validates envelope, repository, run, head, commit reachability, and ancestry. Missing, duplicate, malformed, oversized, abbreviated, zero, unreachable, mismatched, or contradictory identity returns `disposition=refused`, empty commit outputs, a stable `reason`, a JSON `provenance` output, and a job-summary explanation. Run classification only under `identity.usableIf`; the existing `impact.demandedIf(...)` condition then keeps every target demanded on any transport or classification refusal.
163
+
164
+ The helper selects no action family or action version. Callers pass the explicit tagged pins already validated by their `factoryWorkflow()` artifact, keeping action-family and additional-action policy in the generated workflow source.
165
+
166
+ This is identity transport and validation only. The consumer still owns the `workflow_run` trigger and branch policy, job topology and runners, deploy credentials and environments, target declarations, commands, ordering, and convergence/no-op proof. None of those belong in the identity artifact or decision job.
167
+
168
+ ### Generated shell
169
+
170
+ ```ts
171
+ import { assertWorkflowShellParses } from "@patronage/factory-ci";
172
+
173
+ const generated = workflow({/* jobs */});
174
+
175
+ assertWorkflowShellParses(generated.toYamlString(), {
176
+ source: ".github/workflows/verify.ts",
177
+ });
178
+
179
+ generated.writeOrLint({ filePath, ...workflowArtifact.writeOptions });
180
+ ```
181
+
182
+ A generated-workflow lint validates YAML shape. It does not parse the shell inside a `run:` block, so a script that cannot execute at all — a stray `fi`, an unclosed quote — passes every local check and only fails when the runner reaches it. paitronage#1090 shipped exactly that and made every automated preview destroy a parse-time no-op for five days.
183
+
184
+ `assertWorkflowShellParses` extracts each `run:` block from the emitted YAML, neutralizes `${{ }}` expressions (they are substituted before bash sees the script), and parses it with the interpreter the runner would use — `bash -n`, or `sh -n` for a step declaring `sh`, whose grammar is narrower. The interpreter is resolved the way GitHub resolves it: the step's `shell:`, then the job's `defaults.run.shell`, then the workflow's, then bash. `shell:` is a command template, so `bash`, `/bin/sh {0}`, `env FOO=bar bash {0}` and `bash -O extglob {0}` all resolve — options included, since they change the grammar. It throws naming the source and the step.
185
+
186
+ Steps resolving to pwsh, powershell, python, or cmd are left alone. Any other command is refused with an error rather than skipped, so an interpreter this control has not considered cannot pass for a checked one.
187
+
188
+ `workflowShellParseFailures` returns the same findings without throwing, for a caller that wants to report rather than fail.
189
+
190
+ Two scalar forms are refused outright rather than guessed at, because for both of them the text on the page is not the script the runner gets: a folded `run: >` block (YAML folds its line breaks into spaces) and a double-quoted `run: "..."` scalar (its YAML escapes would have to be decoded first). Neither is emitted by any generator in this fleet; a literal `run: |` block always is.
191
+
62
192
  ### Proof reuse
63
193
 
64
194
  ```ts
@@ -66,6 +196,8 @@ import {
66
196
  assertProofReuseCoverage,
67
197
  FACTORY_PROOF_GATE_GUARD,
68
198
  factoryProofGateStep,
199
+ factoryProofReuseSummaryStep,
200
+ factoryProofTimingStartStep,
69
201
  } from "@patronage/factory-ci";
70
202
 
71
203
  import profile from "../../software-factory.profile.json" with { type: "json" };
@@ -78,22 +210,28 @@ const core = job("core", {
78
210
  permissions: { checks: "read", contents: "read" },
79
211
  steps: [
80
212
  step(factoryProofGateStep({ commands: coreCommands, surface: "core" })),
213
+ step(factoryProofTimingStartStep()),
81
214
  ...guardedSteps.map((s) => ({ ...s, if: FACTORY_PROOF_GATE_GUARD })),
215
+ step(factoryProofReuseSummaryStep({ surface: "core" })),
82
216
  ],
83
217
  });
84
218
  ```
85
219
 
86
220
  The **proof-reuse gate** decides whether a hosted job may reuse the local verification the factory already published for this exact head (ADR 0022). Refusing runs hosted CI; it never fails the candidate. The App identity, check name, step id, output names, guard, and every trust predicate are fixed here rather than consumer-configurable — three repositories had grown three answers to the same question and had already drifted.
87
221
 
88
- A proof is reusable only when the complete Checks API result (`filter=all`, every page) establishes one unambiguous newest generation by greatest `started_at`, produced by the pinned App for the exact repository and head, completed successfully with `outcome: passed`, and covering every command identity the guarded surface requires. `mode` is reported as diagnostic metadata, never authorized on: a reduced-mode proof that ran everything a surface requires is reusable.
222
+ A proof is reusable only when the complete Checks API result (`filter=all`, every page) establishes one unambiguous newest generation by greatest `started_at`, produced by the pinned App for the exact repository and head, completed successfully with `outcome: passed`, and covering every command identity the guarded surface requires. Coverage is the union of `executedCommands` and commands released by `notRequiredCommands` only after the gate validates each release against that same proof binding's command-to-target map and identity-bound impact stamp. Missing, malformed, duplicated, affected, unknown, or differently bound release data refuses reuse and runs the hosted surface. `mode` is reported as diagnostic metadata, never authorized on: a reduced-mode proof whose executed and stamp-authorized released commands cover a surface is reusable.
89
223
 
90
224
  The only thing a consumer chooses is **what its surface requires**, expressed as the plain profile command objects that surface selects — a repository with distinct core and docs jobs selects distinct sets and gets distinct required coverage. `proofReuseRequiredCommands()` is the single derivation both the gate and the assertion go through. Identities are baked into the emitted script, so they are held to a plain `[A-Za-z0-9_][\w.:@/-]*` allow-list and shell-quoted at the interpolation site; a selection carrying anything else is unusable and degrades to a gate that always refuses.
91
225
 
226
+ Command names are executable authorization identities, not display labels. They must be unique in the profile; `factory-ci` also refuses a selected set with duplicates so two command strings can never collapse behind one proof identity.
227
+
92
228
  The gate is a **step, not a job**, marked `continue-on-error`, with no `set -e`. A gate job that errored would leave the guarded job `skipped`, which a summary job that only fails on `failure` / `cancelled` reports as green. As a step it fails open by construction: the step errors, the output is never written, the `!= 'true'` guard reads empty, and every command runs. It writes a machine-readable `reason` output — `proven | none | pending | failed | unreadable | incomplete | ambiguous | error` — so a reuse-collapses-to-never regression is visible instead of hidden behind fail-open.
93
229
 
94
230
  **Emit the step's `shell` verbatim.** `factoryProofGateStep()` sets `shell: bash --noprofile --norc {0}` (`FACTORY_PROOF_GATE_SHELL`) and a generator that drops it breaks the gate. GitHub's default for `run:` is `bash -e {0}` — errexit arrives from the invocation, not the script, so omitting `set -e` does not achieve it. Under the default the gate dies at the first non-zero command before its single `GITHUB_OUTPUT` write: no verdict, no `reason`, reuse silently collapsed to never while the job looks like healthy full CI. `shell: bash` is not a substitute; it expands to `bash --noprofile --norc -eo pipefail {0}`. The script is written to survive errexit as well, and its tests execute every case under both shells — a harness that runs this script under friendlier flags than the runner does is worse than no harness.
95
231
 
96
- `assertProofReuseCoverage({ commands, skipped, surface })` is the compile-time guard in front of the runtime `incomplete` refusal: hand it the same selection and the command strings the workflow would skip, and it fails the consumer's build when the two drift apart. Extracting the skipped strings stays with the consumer — this package never parses workflow source, because establishing trust that way is what killed an earlier attempt.
232
+ `assertProofReuseCoverage({ commands, skipped, surface })` is the compile-time guard in front of the runtime `incomplete` refusal: hand it the same selection and the command strings the workflow would skip, and it fails the consumer's build when the two drift apart. Coverage is exact executable coverage. The deprecated `equivalents` input remains only for patch-release source compatibility and is ignored; prose cannot authorize a skip. Extracting the skipped strings stays with the consumer — this package never parses workflow source, because establishing trust that way is what killed an earlier attempt.
233
+
234
+ `factoryProofTimingStartStep()` and `factoryProofReuseSummaryStep()` replace consumer-local proof timing summaries without taking over workflow topology. Put the start step immediately after the gate and the summary step after the guarded work. The helpers keep the established `Start CI timing` / `Record proof-reuse timing` names and distinguish pull-request reuse, pull-request full fallback, and merge-target full execution where the PR-only gate is explicitly not applicable. Reuse links the exact source check and bound head; every path emits a cheap Actions notice. Both steps are `continue-on-error`: missing timing support or an unwritable presentation destination cannot change a required job's conclusion. These helpers change no trust predicate or guard.
97
235
 
98
236
  ### Alchemy entries
99
237
 
@@ -101,7 +239,9 @@ The gate is a **step, not a job**, marked `continue-on-error`, with no `set -e`.
101
239
  import { bundleAlchemyEntry, executeAlchemyEntry } from "@patronage/factory-ci";
102
240
  ```
103
241
 
104
- `bundleAlchemyEntry({ entry, outfile, ... })` flattens a TypeScript Alchemy entry to a single ESM file the Alchemy CLI can run, keeping `alchemy`, `alchemy/*`, `effect`, and `effect/*` external — both packages are identity-sensitive and a second copy breaks them silently. Options: `absWorkingDir`, `packages` (`"external"` by default), `sourcemap`, `target`, `tsconfig`. Returns the absolute outfile path.
242
+ `bundleAlchemyEntry({ entry, outfile, ... })` flattens a TypeScript Alchemy entry to a single ESM file the Alchemy CLI can run, keeping `alchemy`, `alchemy/*`, `effect`, and `effect/*` external — both packages are identity-sensitive and a second copy breaks them silently. Options: `absWorkingDir`, `alias`, `packages` (`"external"` by default), `sourcemap`, `target`, `tsconfig`. Returns the absolute outfile path.
243
+
244
+ `alias` is a specifier-to-target map handed to esbuild. Substitution runs before the `packages` and `external` decisions, so an aliased bare specifier is inlined even under `packages: "external"` — that is how a repo points a workspace-only or duplicated package at one file. Give absolute paths or package names; which aliases a repo needs is consumer policy and this package ships no defaults. `executeAlchemyEntry` passes its whole `bundle` option through, so the map is available there too. Because substitution runs first, an alias on `alchemy`, `effect`, or any of their subpaths would silently defeat the externals and bundle a second copy, so those keys are rejected outright. Targets are not string-matched — no rule over how a path is spelled can survive `..` segments or symlinks — so instead the build's metafile is checked afterwards and the bundle is rejected if it carries any input from a reserved package, however that file was reached. A consumer shim that re-exports `effect` by bare specifier stays external naturally and is allowed.
105
245
 
106
246
  `executeAlchemyEntry({ from, bundle, args, ... })` owns the repeated choreography: resolve the consumer's Alchemy CLI from `from`, bundle the entry, run that CLI under the current Node binary with the absolute bundled entry appended, and throw on spawn errors, signals, missing statuses, or non-zero exits. It returns only after status 0.
107
247
 
@@ -119,6 +259,48 @@ import {
119
259
 
120
260
  `local-pr-<pr>-<short sha>` construction and interpretation live behind one private grammar. `localPreviewStage({ pr, headSha, shaLength? })` accepts a positive PR, a full 40-character SHA, and a 7–40-character slice length (default 12). `parseLocalPreviewStage(stage, expected?)` returns the PR and SHA prefix, optionally proving ownership against a PR and full head SHA. `isLocalPreviewStage(stage, pr?)` is the boolean type guard. The raw regex is not public.
121
261
 
262
+ ### GitHub App installation tokens
263
+
264
+ ```ts
265
+ import { GitHubApiError, mintInstallationToken } from "@patronage/factory-ci";
266
+
267
+ const token = await mintInstallationToken(
268
+ { credentials: operatorGithubApp, owner, repo },
269
+ { timeoutMs: 5000 }
270
+ );
271
+ ```
272
+
273
+ The factory publishes its check runs, and paitronage its proof comments, under the same GitHub App identity — two projects that had each written the same three steps: sign an RS256 app JWT, look the installation up when it is not already known, exchange the JWT for an installation access token.
274
+
275
+ `mintInstallationToken({ credentials, owner, repo }, options?)` is that mechanism and nothing more. `credentials` is `{ appId, installationId?, privateKeyPath }`: the app id GitHub issued, the installation when a consumer has recorded one, and the path to the private key. The key is a _path_, not key material, so no caller has to hold a secret in memory to make this call — and **where that path comes from stays the consumer's**. Operator config, a secret manager, an environment variable: this package neither reads a config file nor knows a key-path convention. `githubAppJwt(credentials, options?)` is the signing step alone, for a caller that needs the app JWT rather than an installation token.
276
+
277
+ Nothing is cached. The token is returned to the caller, which owns its lifetime — this module keeps no copy of the token or the key, and never writes either to any output. A failing GitHub response throws `GitHubApiError`, which carries `status` so a caller can tell a retryable failure (422, 429, 5xx) from a wrong-credentials one.
278
+
279
+ `options` are all injectable seams: `fetch`, `now`, `readPrivateKey`, and a `timeoutMs` per request (five seconds by default). Tests substitute the first three; production passes at most a timeout.
280
+
281
+ ### Vitest suite profiling
282
+
283
+ ```ts
284
+ import { runVitestProfile } from "@patronage/factory-ci";
285
+
286
+ const profile = await runVitestProfile({
287
+ cwd: packageRoot,
288
+ gitDirectory: repositoryRoot,
289
+ maxWorkers: 4,
290
+ outputPath: "/abs/path/profile.json",
291
+ samples: 3,
292
+ slowLimit: 20,
293
+ });
294
+ ```
295
+
296
+ `runVitestProfile(options, dependencies?)` takes N **serial** Vitest runs at one worker count and writes a machine-readable profile after every sample: per-file and per-test timings sorted slowest first, duration statistics across samples, and the machine the samples ran on. Samples never overlap — concurrent runs would measure CPU and I/O contention instead of the worker count under test — and the profile is rewritten after each one, so a run that goes red at sample three still leaves two usable measurements on disk. A failed sample throws `VitestProfileError`, which carries the `exitCode` a caller should exit with.
297
+
298
+ The **environment capture is the reason this is shared**. The #640 runner comparison only resolved because each sample recorded its `cpuModel`: 4-CPU samples that were indistinguishable by label split into two non-overlapping populations, AMD at 15.3–16.2s and Intel at 24.6–24.9s. `captureVitestProfileEnvironment({ cwd, gitDirectory? })` records CPU model and count, available parallelism, total memory, arch, platform, OS release, Node version, the **consumer's** Vitest version (resolved from `cwd`, never this package's), and the Git head and dirtiness. Git failures and an unresolvable Vitest degrade to `null` / `"unknown"` rather than throwing: an artifact from a tarball checkout is still a measurement.
299
+
300
+ `normalizeVitestProfileSample(report, input)` folds one Vitest `--reporter=json` document into a sample; a missing report yields `reportAvailable: false` instead of nothing. `writeVitestProfile(path, profile)` is the atomic write — a partial file must never be readable as a complete measurement. Every emitted document carries `schemaVersion: VITEST_PROFILE_SCHEMA_VERSION` and `tool: VITEST_PROFILE_TOOL` so artifacts from different repositories are comparable and self-identifying.
301
+
302
+ **Everything a repository decides stays with the repository**: worker counts, sample counts, slow-list length, output-path conventions, runner labels, artifact upload, and console output. `onSampleStart` / `onSampleComplete` hand the caller each sample so it can print whatever it prints; this package logs nothing. `stdio` for the Vitest child is the caller's too — `"inherit"` by default, so a caller whose own stdout is structured passes `"ignore"`. A reporting hook is a console, not a control: if `onSampleStart` or `onSampleComplete` throws, the sample still runs and the `VitestProfileError` still wins, and the hook's error surfaces only when the sample was otherwise green. `samples` below one is refused outright — it would resolve green having measured nothing and written no artifact. `now`, `runSample`, and `writeResult` are injectable seams for tests.
303
+
122
304
  ### Published-package contract
123
305
 
124
306
  The tests pack the actual tarball, extract it into a throwaway external consumer, import the built package root without the workspace's `development` condition, assert the exact runtime exports, and exercise the workflow and stage interfaces. This catches source-only successes, stale or missing `dist/`, exports-map mistakes, and accidental tarball growth before the attended release check.
@@ -135,7 +317,7 @@ The credential env-block helper that the audit found repeated was deliberately c
135
317
 
136
318
  ## Releases
137
319
 
138
- Attended and hand-cut, on the same terms as `@patronage/alchemy-d1-state`: bump the version, run the workspace checks, read the `npm pack --dry-run` file list, publish, tag `factory-ci@<version>`. There is no changesets setup and no automatic release trigger, by design. Consumers pin exact versions.
320
+ Attended and hand-cut, on the same terms as `@patronage/alchemy-d1-state`: bump the version, run the workspace checks, read the `npm pack --dry-run` file list, build and approve one pnpm tarball, publish that exact tarball with an explicit channel tag, then tag `factory-ci@<version>`. Publishing the package directory is not allowed because its `prepack` build would replace the reviewed bytes. There is no changesets setup and no automatic release trigger, by design. Consumers pin exact versions.
139
321
 
140
322
  ## License
141
323