@patronage/factory-ci 0.2.0 → 1.0.0-alpha.4
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 +49 -2
- package/dist/index.d.ts +124 -9
- package/dist/index.js +416 -9
- package/package.json +4 -1
- package/src/bundle-alchemy-entry.ts +94 -1
- package/src/github-app-token.ts +162 -0
- package/src/index.ts +13 -0
- package/src/proof-reuse-gate.ts +13 -18
- package/src/workflow-shell-lint.ts +462 -0
package/README.md
CHANGED
|
@@ -59,6 +59,30 @@ workflow({/* caller-owned jobs and topology */}).writeOrLint({
|
|
|
59
59
|
|
|
60
60
|
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
61
|
|
|
62
|
+
### Generated shell
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { assertWorkflowShellParses } from "@patronage/factory-ci";
|
|
66
|
+
|
|
67
|
+
const generated = workflow({/* jobs */});
|
|
68
|
+
|
|
69
|
+
assertWorkflowShellParses(generated.toYamlString(), {
|
|
70
|
+
source: ".github/workflows/verify.ts",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
generated.writeOrLint({ filePath, ...workflowArtifact.writeOptions });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
`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.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
`workflowShellParseFailures` returns the same findings without throwing, for a caller that wants to report rather than fail.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
62
86
|
### Proof reuse
|
|
63
87
|
|
|
64
88
|
```ts
|
|
@@ -89,11 +113,13 @@ A proof is reusable only when the complete Checks API result (`filter=all`, ever
|
|
|
89
113
|
|
|
90
114
|
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
115
|
|
|
116
|
+
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.
|
|
117
|
+
|
|
92
118
|
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
119
|
|
|
94
120
|
**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
121
|
|
|
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.
|
|
122
|
+
`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.
|
|
97
123
|
|
|
98
124
|
### Alchemy entries
|
|
99
125
|
|
|
@@ -101,7 +127,9 @@ The gate is a **step, not a job**, marked `continue-on-error`, with no `set -e`.
|
|
|
101
127
|
import { bundleAlchemyEntry, executeAlchemyEntry } from "@patronage/factory-ci";
|
|
102
128
|
```
|
|
103
129
|
|
|
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.
|
|
130
|
+
`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.
|
|
131
|
+
|
|
132
|
+
`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
133
|
|
|
106
134
|
`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
135
|
|
|
@@ -119,6 +147,25 @@ import {
|
|
|
119
147
|
|
|
120
148
|
`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
149
|
|
|
150
|
+
### GitHub App installation tokens
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { GitHubApiError, mintInstallationToken } from "@patronage/factory-ci";
|
|
154
|
+
|
|
155
|
+
const token = await mintInstallationToken(
|
|
156
|
+
{ credentials: operatorGithubApp, owner, repo },
|
|
157
|
+
{ timeoutMs: 5000 }
|
|
158
|
+
);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
`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.
|
|
164
|
+
|
|
165
|
+
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.
|
|
166
|
+
|
|
167
|
+
`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.
|
|
168
|
+
|
|
122
169
|
### Published-package contract
|
|
123
170
|
|
|
124
171
|
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.
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,18 @@ interface BundleAlchemyEntryOptions {
|
|
|
60
60
|
readonly outfile: string;
|
|
61
61
|
/** esbuild's working directory; also the base for relative paths. */
|
|
62
62
|
readonly absWorkingDir?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Import specifiers to rewrite before resolution, passed straight to
|
|
65
|
+
* esbuild's `alias`. Substitution happens before the `packages` and
|
|
66
|
+
* `external` decisions, so an aliased bare specifier is inlined even under
|
|
67
|
+
* `packages: "external"`. Values are resolved the way esbuild resolves any
|
|
68
|
+
* import, so give absolute paths or package names — which aliases a repo
|
|
69
|
+
* needs is the consumer's policy and this package bakes in none. A key on
|
|
70
|
+
* `alchemy`, `effect`, or a subpath of either is rejected outright, and the
|
|
71
|
+
* finished bundle is checked for files from those packages however they were
|
|
72
|
+
* reached.
|
|
73
|
+
*/
|
|
74
|
+
readonly alias?: Readonly<Record<string, string>>;
|
|
63
75
|
/**
|
|
64
76
|
* `"external"` (default) leaves every bare import outside the entry's own
|
|
65
77
|
* source graph to Node's resolver at run time — the entry's TypeScript is
|
|
@@ -194,6 +206,59 @@ interface FactoryWorkflowArtifact<Additional extends Readonly<Record<string, Pin
|
|
|
194
206
|
*/
|
|
195
207
|
declare const factoryWorkflow: <const Additional extends Readonly<Record<string, PinnedAction>> = Record<never, never>>(options: FactoryWorkflowOptions<Additional>) => FactoryWorkflowArtifact<Additional>;
|
|
196
208
|
//#endregion
|
|
209
|
+
//#region src/github-app-token.d.ts
|
|
210
|
+
/**
|
|
211
|
+
* What a consumer must know to mint: the app id, where the private key is, and
|
|
212
|
+
* — when it has been recorded — which installation to mint against.
|
|
213
|
+
*
|
|
214
|
+
* `installationId` is optional because the installation is discoverable from
|
|
215
|
+
* the repository. `privateKeyPath` is a path rather than key material so no
|
|
216
|
+
* consumer has to hold a secret in memory to call this, and so the key-path
|
|
217
|
+
* convention stays the consumer's.
|
|
218
|
+
*/
|
|
219
|
+
interface GithubAppCredentials {
|
|
220
|
+
appId: number | string;
|
|
221
|
+
installationId?: number;
|
|
222
|
+
privateKeyPath: string;
|
|
223
|
+
}
|
|
224
|
+
/** Carries the HTTP status so a caller can tell a retryable failure apart. */
|
|
225
|
+
declare class GitHubApiError extends Error {
|
|
226
|
+
readonly status: number;
|
|
227
|
+
constructor(status: number, statusText: string);
|
|
228
|
+
}
|
|
229
|
+
interface GithubAppTokenOptions {
|
|
230
|
+
/** Injectable `fetch` (tests, or a caller with its own instrumented one). */
|
|
231
|
+
fetch?: typeof fetch;
|
|
232
|
+
/** Wall clock in milliseconds; only the JWT's validity window uses it. */
|
|
233
|
+
now?: () => number;
|
|
234
|
+
/** Injectable key read, so a caller can hold the PEM itself if it must. */
|
|
235
|
+
readPrivateKey?: (privateKeyPath: string) => Buffer | string;
|
|
236
|
+
/** Per-request timeout; defaults to five seconds. */
|
|
237
|
+
timeoutMs?: number;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The signed app JWT GitHub accepts as `Authorization: Bearer` for the App
|
|
241
|
+
* endpoints. `iss` is stringified because GitHub accepts either spelling and a
|
|
242
|
+
* numeric app id must not depend on JSON's number formatting.
|
|
243
|
+
*
|
|
244
|
+
* The signature is produced from the key on disk and returned; the key
|
|
245
|
+
* material itself never leaves this call.
|
|
246
|
+
*/
|
|
247
|
+
declare const githubAppJwt: (credentials: GithubAppCredentials, options?: Pick<GithubAppTokenOptions, "now" | "readPrivateKey">) => string;
|
|
248
|
+
/**
|
|
249
|
+
* Mint an installation access token for one repository.
|
|
250
|
+
*
|
|
251
|
+
* When the credentials omit `installationId`, the installation is discovered
|
|
252
|
+
* from the repository first — the same call every consumer had written for
|
|
253
|
+
* itself. Nothing is cached: the token is returned to the caller and this
|
|
254
|
+
* module keeps no copy.
|
|
255
|
+
*/
|
|
256
|
+
declare const mintInstallationToken: (input: {
|
|
257
|
+
credentials: GithubAppCredentials;
|
|
258
|
+
owner: string;
|
|
259
|
+
repo: string;
|
|
260
|
+
}, options?: GithubAppTokenOptions) => Promise<string>;
|
|
261
|
+
//#endregion
|
|
197
262
|
//#region src/execute-alchemy-entry.d.ts
|
|
198
263
|
interface ExecuteAlchemyEntryOptions {
|
|
199
264
|
/**
|
|
@@ -343,9 +408,11 @@ interface ProofReuseCommand {
|
|
|
343
408
|
*
|
|
344
409
|
* `undefined` means the selection is unusable — not an array, empty, or
|
|
345
410
|
* carrying an entry whose `command` is blank or whose `name` is not a plain
|
|
346
|
-
* command identity
|
|
347
|
-
*
|
|
348
|
-
*
|
|
411
|
+
* command identity, or carrying duplicate names. A name is the executable
|
|
412
|
+
* authorization identity recorded in proof, so two commands may never collapse
|
|
413
|
+
* behind one. An empty required set would make *every* passing proof trivially
|
|
414
|
+
* covering, so it is never silently treated as "requires nothing"; callers
|
|
415
|
+
* must refuse instead.
|
|
349
416
|
*/
|
|
350
417
|
declare const proofReuseRequiredCommands: (commands: readonly ProofReuseCommand[]) => readonly string[] | undefined;
|
|
351
418
|
interface FactoryProofGateOptions {
|
|
@@ -404,10 +471,8 @@ interface ProofReuseCoverageInput {
|
|
|
404
471
|
/** The same selection handed to the gate for this surface. */
|
|
405
472
|
readonly commands: readonly ProofReuseCommand[];
|
|
406
473
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* prose rationale for why some narrower command is "equivalent enough" is
|
|
410
|
-
* exactly what this assertion exists to force into the open.
|
|
474
|
+
* @deprecated Ignored. Consumer prose cannot authorize executable coverage;
|
|
475
|
+
* retained only so the 0.2.1 security patch remains source-compatible.
|
|
411
476
|
*/
|
|
412
477
|
readonly equivalents?: Readonly<Record<string, string>>;
|
|
413
478
|
/**
|
|
@@ -440,10 +505,60 @@ interface ProofReuseCoverageReport {
|
|
|
440
505
|
*/
|
|
441
506
|
declare const proofReuseCoverage: ({
|
|
442
507
|
commands,
|
|
443
|
-
equivalents,
|
|
444
508
|
skipped
|
|
445
509
|
}: ProofReuseCoverageInput) => ProofReuseCoverageReport;
|
|
446
510
|
/** `proofReuseCoverage`, as a build failure. */
|
|
447
511
|
declare const assertProofReuseCoverage: (input: ProofReuseCoverageInput) => ProofReuseCoverageReport;
|
|
448
512
|
//#endregion
|
|
449
|
-
|
|
513
|
+
//#region src/workflow-shell-lint.d.ts
|
|
514
|
+
/**
|
|
515
|
+
* Parse-check the shell embedded in generated workflow YAML (#376).
|
|
516
|
+
*
|
|
517
|
+
* The generated-workflow lint validates YAML shape. It never parses the shell
|
|
518
|
+
* inside a `run:` block, so a script that cannot execute at all — a stray
|
|
519
|
+
* `fi`, an unclosed quote, a `then` with no `if` — passes every local check
|
|
520
|
+
* and only fails when the runner reaches it. That is not hypothetical:
|
|
521
|
+
* paitronage#1090 shipped a stray `fi` into a generated workflow and made
|
|
522
|
+
* every automated preview destroy a parse-time no-op for five days.
|
|
523
|
+
*
|
|
524
|
+
* `bash -n` is the whole control: it parses without executing. It lives here,
|
|
525
|
+
* once, rather than as a contract test in each consumer, because a guard
|
|
526
|
+
* copied per repository is a guard that exists in some of them.
|
|
527
|
+
*/
|
|
528
|
+
/** One `run:` block that bash refuses to parse. */
|
|
529
|
+
interface WorkflowShellParseFailure {
|
|
530
|
+
/** The step's `name:` when the YAML carried one. */
|
|
531
|
+
readonly step?: string;
|
|
532
|
+
/** The script as bash saw it, expressions already neutralized. */
|
|
533
|
+
readonly script: string;
|
|
534
|
+
/** What bash said. */
|
|
535
|
+
readonly stderr: string;
|
|
536
|
+
}
|
|
537
|
+
/** A `run:` block lifted out of generated YAML, with its step's `shell:`. */
|
|
538
|
+
interface RunBlock {
|
|
539
|
+
readonly script: string;
|
|
540
|
+
readonly shell?: string;
|
|
541
|
+
readonly step?: string;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Every `run:` block in a generated workflow, paired with the `shell:` its
|
|
545
|
+
* step declares.
|
|
546
|
+
*
|
|
547
|
+
* Deliberately a scanner over the emitted text and not a YAML parse: this
|
|
548
|
+
* package takes no dependency it does not need, and the emitted shape is one
|
|
549
|
+
* generator's output, not arbitrary YAML. It reads both block scalars
|
|
550
|
+
* (`run: |-`) and inline scripts.
|
|
551
|
+
*/
|
|
552
|
+
declare const workflowRunBlocks: (yaml: string) => RunBlock[];
|
|
553
|
+
/** Every `run:` block its interpreter refuses to parse. Empty means sound. */
|
|
554
|
+
declare const workflowShellParseFailures: (yaml: string) => WorkflowShellParseFailure[];
|
|
555
|
+
/**
|
|
556
|
+
* Fail the generated-workflow lint when any embedded `run:` block is not
|
|
557
|
+
* parseable bash. Call it on the YAML a generator is about to write, so the
|
|
558
|
+
* defect is caught at generation rather than by the runner.
|
|
559
|
+
*/
|
|
560
|
+
declare const assertWorkflowShellParses: (yaml: string, options: {
|
|
561
|
+
readonly source: string;
|
|
562
|
+
}) => void;
|
|
563
|
+
//#endregion
|
|
564
|
+
export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, workflowRunBlocks, workflowShellParseFailures };
|