@patronage/factory-ci 0.2.1 → 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 +46 -1
- package/dist/index.d.ts +117 -1
- package/dist/index.js +406 -3
- 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/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
|
|
@@ -103,7 +127,9 @@ The gate is a **step, not a job**, marked `continue-on-error`, with no `set -e`.
|
|
|
103
127
|
import { bundleAlchemyEntry, executeAlchemyEntry } from "@patronage/factory-ci";
|
|
104
128
|
```
|
|
105
129
|
|
|
106
|
-
`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.
|
|
107
133
|
|
|
108
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.
|
|
109
135
|
|
|
@@ -121,6 +147,25 @@ import {
|
|
|
121
147
|
|
|
122
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.
|
|
123
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
|
+
|
|
124
169
|
### Published-package contract
|
|
125
170
|
|
|
126
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
|
/**
|
|
@@ -445,4 +510,55 @@ declare const proofReuseCoverage: ({
|
|
|
445
510
|
/** `proofReuseCoverage`, as a build failure. */
|
|
446
511
|
declare const assertProofReuseCoverage: (input: ProofReuseCoverageInput) => ProofReuseCoverageReport;
|
|
447
512
|
//#endregion
|
|
448
|
-
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { build } from "esbuild";
|
|
5
|
-
import {
|
|
5
|
+
import { createSign } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
8
|
//#region src/actions.ts
|
|
7
9
|
/**
|
|
8
10
|
* The canonical Node 24 family shared by factory-project workflows.
|
|
@@ -40,6 +42,50 @@ const ALCHEMY_EXTERNALS = [
|
|
|
40
42
|
"effect/*"
|
|
41
43
|
];
|
|
42
44
|
/**
|
|
45
|
+
* The package names behind `ALCHEMY_EXTERNALS`, derived rather than restated so
|
|
46
|
+
* a future external can never be guarded by only one of two lists.
|
|
47
|
+
*/
|
|
48
|
+
const RESERVED_ALIAS_PACKAGES = [...new Set(ALCHEMY_EXTERNALS.map((external) => external.replace(/\/\*$/u, "").toLowerCase()))];
|
|
49
|
+
const reservedAliasKey = (key) => RESERVED_ALIAS_PACKAGES.find((name) => key === name || key.startsWith(`${name}/`));
|
|
50
|
+
/**
|
|
51
|
+
* Refuse an alias *key* that is itself a reserved package or one of its
|
|
52
|
+
* subpaths. esbuild substitutes aliases before it decides what is external, so
|
|
53
|
+
* such a key defeats `external` outright. Keys are compared, never resolved,
|
|
54
|
+
* which is what makes string matching sound here.
|
|
55
|
+
*/
|
|
56
|
+
const assertAliasKeysAreAdmissible = (alias) => {
|
|
57
|
+
for (const key of Object.keys(alias)) {
|
|
58
|
+
const reserved = reservedAliasKey(key);
|
|
59
|
+
if (reserved) throw new Error(`bundleAlchemyEntry cannot alias "${key}": ${reserved} must stay external because its identity is shared with the consumer's runtime, and esbuild applies aliases before external matching. Point the consumer's own resolution at one copy instead.`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Whether a bundled input lives inside a reserved package.
|
|
64
|
+
*
|
|
65
|
+
* Metafile inputs are paths esbuild resolved and normalized itself, so
|
|
66
|
+
* comparing whole segments here answers what was *bundled* rather than how the
|
|
67
|
+
* config was spelled. Segments are compared case-insensitively: on a
|
|
68
|
+
* case-insensitive filesystem `node_modules/Effect/…` resolves to the real
|
|
69
|
+
* package and the metafile keeps the caller's spelling.
|
|
70
|
+
*/
|
|
71
|
+
const isReservedInput = (input) => {
|
|
72
|
+
const segments = input.toLowerCase().split(/[/\\]+/u);
|
|
73
|
+
return segments.some((segment, position) => position > 0 && segments[position - 1] === "node_modules" && RESERVED_ALIAS_PACKAGES.includes(segment));
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Assert the externals contract against the bundle esbuild actually produced.
|
|
77
|
+
*
|
|
78
|
+
* Checking alias *values* instead was unsound by construction: `..` segments,
|
|
79
|
+
* symlinks, and every other spelling of the same file each need another string
|
|
80
|
+
* rule, and the scanner loses. The metafile records the inputs after esbuild's
|
|
81
|
+
* own resolution and normalization, so one check closes the whole class —
|
|
82
|
+
* whatever route reached an identity-sensitive package, it shows up here.
|
|
83
|
+
*/
|
|
84
|
+
const assertNoReservedInputs = (inputs) => {
|
|
85
|
+
const offenders = inputs.filter(isReservedInput);
|
|
86
|
+
if (offenders.length > 0) throw new Error(`bundleAlchemyEntry refused a bundle carrying a second copy of an identity-sensitive package: ${offenders.join(", ")}. Those packages must resolve from the consumer's runtime, so nothing may pull their files into the bundle — an alias that re-exports them by bare specifier stays external and is fine.`);
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
43
89
|
* Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
|
|
44
90
|
* `effect` external (#268).
|
|
45
91
|
*
|
|
@@ -53,22 +99,26 @@ const ALCHEMY_EXTERNALS = [
|
|
|
53
99
|
* Returns the absolute path of the file written.
|
|
54
100
|
*/
|
|
55
101
|
const bundleAlchemyEntry = async (options) => {
|
|
102
|
+
if (options.alias) assertAliasKeysAreAdmissible(options.alias);
|
|
56
103
|
const root = options.absWorkingDir ? path.resolve(options.absWorkingDir) : process.cwd();
|
|
57
104
|
const outfile = path.resolve(root, options.outfile);
|
|
58
105
|
await mkdir(path.dirname(outfile), { recursive: true });
|
|
59
|
-
await build({
|
|
106
|
+
const result = await build({
|
|
60
107
|
absWorkingDir: root,
|
|
61
108
|
bundle: true,
|
|
62
109
|
entryPoints: [path.resolve(root, options.entry)],
|
|
63
110
|
external: ALCHEMY_EXTERNALS,
|
|
64
111
|
format: "esm",
|
|
112
|
+
metafile: true,
|
|
65
113
|
outfile,
|
|
66
114
|
packages: options.packages ?? "external",
|
|
67
115
|
platform: "node",
|
|
68
116
|
sourcemap: options.sourcemap ?? false,
|
|
69
117
|
target: options.target ?? "node24",
|
|
118
|
+
...options.alias ? { alias: { ...options.alias } } : {},
|
|
70
119
|
...options.tsconfig ? { tsconfig: path.resolve(root, options.tsconfig) } : {}
|
|
71
120
|
});
|
|
121
|
+
assertNoReservedInputs(Object.keys(result.metafile.inputs));
|
|
72
122
|
return outfile;
|
|
73
123
|
};
|
|
74
124
|
//#endregion
|
|
@@ -210,6 +260,95 @@ const factoryWorkflow = (options) => {
|
|
|
210
260
|
});
|
|
211
261
|
};
|
|
212
262
|
//#endregion
|
|
263
|
+
//#region src/github-app-token.ts
|
|
264
|
+
/**
|
|
265
|
+
* Minting a GitHub App installation token: the RS256 app JWT, the optional
|
|
266
|
+
* installation lookup, and the token exchange (#617).
|
|
267
|
+
*
|
|
268
|
+
* Two projects had grown the same three steps independently — the factory's
|
|
269
|
+
* check-run publisher and paitronage's proof-comment publisher — which is the
|
|
270
|
+
* admitted-on-repetition bar. Only the *mechanism* lives here. Where the
|
|
271
|
+
* private key comes from, how the app id is configured, and what the token is
|
|
272
|
+
* then used for stay with each consumer: this module is handed credentials and
|
|
273
|
+
* returns a token.
|
|
274
|
+
*/
|
|
275
|
+
/** The default request budget, matching the factory's other GitHub writes. */
|
|
276
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
277
|
+
/** Nine-minute JWT lifetime, backdated a minute against runner clock skew. */
|
|
278
|
+
const JWT_BACKDATE_SECONDS = 60;
|
|
279
|
+
const JWT_LIFETIME_SECONDS = 600;
|
|
280
|
+
/** Carries the HTTP status so a caller can tell a retryable failure apart. */
|
|
281
|
+
var GitHubApiError = class extends Error {
|
|
282
|
+
status;
|
|
283
|
+
constructor(status, statusText) {
|
|
284
|
+
super(`GitHub API ${status} ${statusText}`);
|
|
285
|
+
this.name = "GitHubApiError";
|
|
286
|
+
this.status = status;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const base64url = (value) => Buffer.from(value).toString("base64url");
|
|
290
|
+
/**
|
|
291
|
+
* The signed app JWT GitHub accepts as `Authorization: Bearer` for the App
|
|
292
|
+
* endpoints. `iss` is stringified because GitHub accepts either spelling and a
|
|
293
|
+
* numeric app id must not depend on JSON's number formatting.
|
|
294
|
+
*
|
|
295
|
+
* The signature is produced from the key on disk and returned; the key
|
|
296
|
+
* material itself never leaves this call.
|
|
297
|
+
*/
|
|
298
|
+
const githubAppJwt = (credentials, options = {}) => {
|
|
299
|
+
const nowMs = (options.now ?? Date.now)();
|
|
300
|
+
const issuedAt = Math.floor(nowMs / 1e3) - JWT_BACKDATE_SECONDS;
|
|
301
|
+
const unsigned = `${base64url(JSON.stringify({
|
|
302
|
+
alg: "RS256",
|
|
303
|
+
typ: "JWT"
|
|
304
|
+
}))}.${base64url(JSON.stringify({
|
|
305
|
+
exp: issuedAt + JWT_LIFETIME_SECONDS,
|
|
306
|
+
iat: issuedAt,
|
|
307
|
+
iss: String(credentials.appId)
|
|
308
|
+
}))}`;
|
|
309
|
+
const readKey = options.readPrivateKey ?? readFileSync;
|
|
310
|
+
const signer = createSign("RSA-SHA256");
|
|
311
|
+
signer.update(unsigned);
|
|
312
|
+
signer.end();
|
|
313
|
+
return `${unsigned}.${signer.sign(readKey(credentials.privateKeyPath), "base64url")}`;
|
|
314
|
+
};
|
|
315
|
+
const githubAppJson = async (request, url, jwt, method, timeoutMs) => {
|
|
316
|
+
const response = await request(url, {
|
|
317
|
+
headers: {
|
|
318
|
+
Accept: "application/vnd.github+json",
|
|
319
|
+
Authorization: `Bearer ${jwt}`,
|
|
320
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
321
|
+
},
|
|
322
|
+
method,
|
|
323
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
324
|
+
});
|
|
325
|
+
if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
|
|
326
|
+
return await response.json();
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Mint an installation access token for one repository.
|
|
330
|
+
*
|
|
331
|
+
* When the credentials omit `installationId`, the installation is discovered
|
|
332
|
+
* from the repository first — the same call every consumer had written for
|
|
333
|
+
* itself. Nothing is cached: the token is returned to the caller and this
|
|
334
|
+
* module keeps no copy.
|
|
335
|
+
*/
|
|
336
|
+
const mintInstallationToken = async (input, options = {}) => {
|
|
337
|
+
const { credentials } = input;
|
|
338
|
+
const request = options.fetch ?? fetch;
|
|
339
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
340
|
+
const jwt = githubAppJwt(credentials, options);
|
|
341
|
+
let { installationId } = credentials;
|
|
342
|
+
if (installationId === void 0) {
|
|
343
|
+
const installation = await githubAppJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/installation`, jwt, "GET", timeoutMs);
|
|
344
|
+
if (typeof installation.id !== "number") throw new TypeError("GitHub App installation response omitted id");
|
|
345
|
+
installationId = installation.id;
|
|
346
|
+
}
|
|
347
|
+
const minted = await githubAppJson(request, `https://api.github.com/app/installations/${installationId}/access_tokens`, jwt, "POST", timeoutMs);
|
|
348
|
+
if (typeof minted.token !== "string") throw new TypeError("GitHub App token response omitted token");
|
|
349
|
+
return minted.token;
|
|
350
|
+
};
|
|
351
|
+
//#endregion
|
|
213
352
|
//#region src/execute-alchemy-entry.ts
|
|
214
353
|
/**
|
|
215
354
|
* Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
|
|
@@ -759,4 +898,268 @@ const assertProofReuseCoverage = (input) => {
|
|
|
759
898
|
throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
|
|
760
899
|
};
|
|
761
900
|
//#endregion
|
|
762
|
-
|
|
901
|
+
//#region src/workflow-shell-lint.ts
|
|
902
|
+
const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
|
|
903
|
+
/**
|
|
904
|
+
* GitHub evaluates `${{ }}` before bash ever sees the script, and what it
|
|
905
|
+
* substitutes is not knowable here. Neutralizing each expression to one plain
|
|
906
|
+
* word is what the runner's *shape* looks like: a value in argument position.
|
|
907
|
+
* Leaving them in would make every workflow fail to parse; expanding them to
|
|
908
|
+
* nothing would silently change quoting.
|
|
909
|
+
*
|
|
910
|
+
* Scanned rather than matched with a lazy regex, because `}}` occurs inside
|
|
911
|
+
* Actions string literals: `format('refs/{{0}}', github.ref_name)` escapes a
|
|
912
|
+
* literal brace pair that way, and stopping there would leave half an
|
|
913
|
+
* expression in the script and report a parse error the runner never sees.
|
|
914
|
+
*/
|
|
915
|
+
const neutralizeExpressions = (script) => {
|
|
916
|
+
let out = "";
|
|
917
|
+
let cursor = 0;
|
|
918
|
+
while (cursor < script.length) {
|
|
919
|
+
const start = script.indexOf("${{", cursor);
|
|
920
|
+
if (start === -1) {
|
|
921
|
+
out += script.slice(cursor);
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
out += script.slice(cursor, start);
|
|
925
|
+
let scan = start + 3;
|
|
926
|
+
let quote;
|
|
927
|
+
let end = -1;
|
|
928
|
+
while (scan < script.length) {
|
|
929
|
+
const char = script[scan];
|
|
930
|
+
if (quote === void 0) {
|
|
931
|
+
if (char === "'" || char === "\"") quote = char;
|
|
932
|
+
else if (char === "}" && script[scan + 1] === "}") {
|
|
933
|
+
end = scan + 2;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
} else if (char === quote) if (script[scan + 1] === quote) scan += 1;
|
|
937
|
+
else quote = void 0;
|
|
938
|
+
scan += 1;
|
|
939
|
+
}
|
|
940
|
+
if (end === -1) {
|
|
941
|
+
out += script.slice(start);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
out += "FACTORY_ACTIONS_EXPRESSION";
|
|
945
|
+
cursor = end;
|
|
946
|
+
}
|
|
947
|
+
return out;
|
|
948
|
+
};
|
|
949
|
+
/** Strip one layer of YAML single quoting from a scalar value. */
|
|
950
|
+
const unquote = (raw) => {
|
|
951
|
+
const value = raw.trim();
|
|
952
|
+
if (value.startsWith("'") && value.endsWith("'") && value.length > 1) return value.slice(1, -1).replaceAll("''", "'");
|
|
953
|
+
return value;
|
|
954
|
+
};
|
|
955
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
956
|
+
/**
|
|
957
|
+
* Where a mapping key sits, counting the `- ` sequence marker as indentation:
|
|
958
|
+
* `- name:` and the `run:` below it are siblings in the same step even though
|
|
959
|
+
* their raw columns differ by two.
|
|
960
|
+
*/
|
|
961
|
+
const keyIndentOf = (line) => indentOf(line) + (line.trimStart().startsWith("- ") ? 2 : 0);
|
|
962
|
+
/** A sibling scalar of the `run:` key under inspection, when the line is one. */
|
|
963
|
+
const keyValueAt = (line, indent, key) => {
|
|
964
|
+
if (keyIndentOf(line) !== indent) return;
|
|
965
|
+
const rest = line.trimStart().replace(/^-\s+/u, "");
|
|
966
|
+
return rest.startsWith(`${key}:`) ? rest.slice(key.length + 1) : void 0;
|
|
967
|
+
};
|
|
968
|
+
/**
|
|
969
|
+
* Every `defaults: { run: { shell } }` in the document, with the span it
|
|
970
|
+
* governs: the mapping that declares it, which is the whole workflow at the
|
|
971
|
+
* top level and one job under `jobs:`.
|
|
972
|
+
*
|
|
973
|
+
* GitHub resolves a step's interpreter as step `shell:`, then the job's
|
|
974
|
+
* default, then the workflow's, then bash. A scanner that only looked at the
|
|
975
|
+
* step would call every step in a `defaults.run.shell: sh` workflow bash and
|
|
976
|
+
* report a pass for scripts `sh` cannot parse — the very false negative this
|
|
977
|
+
* control exists to close.
|
|
978
|
+
*/
|
|
979
|
+
const defaultShellScopes = (lines) => {
|
|
980
|
+
const scopes = [];
|
|
981
|
+
for (const [index, line] of lines.entries()) {
|
|
982
|
+
if (line.trim() !== "defaults:") continue;
|
|
983
|
+
const depth = keyIndentOf(line);
|
|
984
|
+
const shell = (() => {
|
|
985
|
+
let inRun = false;
|
|
986
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
987
|
+
const candidate = lines[cursor];
|
|
988
|
+
if (candidate.trim().length === 0) continue;
|
|
989
|
+
if (keyIndentOf(candidate) <= depth) return;
|
|
990
|
+
if (keyValueAt(candidate, depth + 2, "run") !== void 0) {
|
|
991
|
+
inRun = true;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if (keyIndentOf(candidate) <= depth + 2) {
|
|
995
|
+
inRun = false;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const value = inRun ? keyValueAt(candidate, depth + 4, "shell") : void 0;
|
|
999
|
+
if (value !== void 0) return unquote(value);
|
|
1000
|
+
}
|
|
1001
|
+
})();
|
|
1002
|
+
if (shell === void 0) continue;
|
|
1003
|
+
let start = 0;
|
|
1004
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
|
|
1005
|
+
start = cursor;
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
let end = lines.length;
|
|
1009
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
|
|
1010
|
+
end = cursor;
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
scopes.push({
|
|
1014
|
+
depth,
|
|
1015
|
+
end,
|
|
1016
|
+
shell,
|
|
1017
|
+
start
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return scopes;
|
|
1021
|
+
};
|
|
1022
|
+
/** The innermost `defaults.run.shell` governing a line, if any. */
|
|
1023
|
+
const inheritedShell = (scopes, index) => scopes.filter((scope) => index >= scope.start && index < scope.end).toSorted((left, right) => right.depth - left.depth).at(0)?.shell;
|
|
1024
|
+
/**
|
|
1025
|
+
* Every `run:` block in a generated workflow, paired with the `shell:` its
|
|
1026
|
+
* step declares.
|
|
1027
|
+
*
|
|
1028
|
+
* Deliberately a scanner over the emitted text and not a YAML parse: this
|
|
1029
|
+
* package takes no dependency it does not need, and the emitted shape is one
|
|
1030
|
+
* generator's output, not arbitrary YAML. It reads both block scalars
|
|
1031
|
+
* (`run: |-`) and inline scripts.
|
|
1032
|
+
*/
|
|
1033
|
+
const workflowRunBlocks = (yaml) => {
|
|
1034
|
+
const lines = yaml.split("\n");
|
|
1035
|
+
const scopes = defaultShellScopes(lines);
|
|
1036
|
+
const blocks = [];
|
|
1037
|
+
for (const [index, line] of lines.entries()) {
|
|
1038
|
+
const match = RUN_KEY.exec(line);
|
|
1039
|
+
if (match?.groups === void 0) continue;
|
|
1040
|
+
const keyIndent = keyIndentOf(line);
|
|
1041
|
+
const inline = match.groups.inline.trim();
|
|
1042
|
+
let script;
|
|
1043
|
+
let end = index;
|
|
1044
|
+
if (inline.startsWith(">")) throw new Error(`folded (\`run: >\`) scripts are not supported: YAML folds their line breaks into spaces, so what bash parses is not what is written. Use a literal block (\`run: |\`).`);
|
|
1045
|
+
if (inline.trimStart().startsWith("\"")) throw new Error(`double-quoted \`run:\` scalars are not supported: their YAML escapes would have to be decoded before bash sees them. Use a literal block (\`run: |\`) or an unquoted scalar.`);
|
|
1046
|
+
if (inline.startsWith("|")) {
|
|
1047
|
+
const body = [];
|
|
1048
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
1049
|
+
const candidate = lines[cursor];
|
|
1050
|
+
if (candidate.trim().length > 0 && indentOf(candidate) <= keyIndent) break;
|
|
1051
|
+
body.push(candidate);
|
|
1052
|
+
end = cursor;
|
|
1053
|
+
}
|
|
1054
|
+
const strip = Math.min(...body.filter((entry) => entry.trim().length > 0).map((entry) => indentOf(entry)));
|
|
1055
|
+
script = body.map((entry) => entry.slice(strip)).join("\n");
|
|
1056
|
+
} else if (inline.length > 0) script = unquote(inline);
|
|
1057
|
+
else continue;
|
|
1058
|
+
let shell;
|
|
1059
|
+
let step;
|
|
1060
|
+
const readSibling = (line_) => {
|
|
1061
|
+
shell ??= keyValueAt(line_, keyIndent, "shell");
|
|
1062
|
+
step ??= keyValueAt(line_, keyIndent, "name");
|
|
1063
|
+
};
|
|
1064
|
+
for (let cursor = index; cursor >= 0 && keyIndentOf(lines[cursor]) >= keyIndent; cursor -= 1) {
|
|
1065
|
+
readSibling(lines[cursor]);
|
|
1066
|
+
if (lines[cursor].trimStart().startsWith("- ")) break;
|
|
1067
|
+
}
|
|
1068
|
+
for (let cursor = end + 1; cursor < lines.length; cursor += 1) {
|
|
1069
|
+
if (keyIndentOf(lines[cursor]) < keyIndent || lines[cursor].trimStart().startsWith("- ")) break;
|
|
1070
|
+
readSibling(lines[cursor]);
|
|
1071
|
+
}
|
|
1072
|
+
const effectiveShell = shell === void 0 ? inheritedShell(scopes, index) : unquote(shell);
|
|
1073
|
+
blocks.push({
|
|
1074
|
+
script,
|
|
1075
|
+
...effectiveShell === void 0 ? {} : { shell: effectiveShell },
|
|
1076
|
+
...step === void 0 ? {} : { step: unquote(step) }
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
return blocks;
|
|
1080
|
+
};
|
|
1081
|
+
/** `NAME=value` in a shell command template, as `env` takes them. */
|
|
1082
|
+
const ASSIGNMENT = /^[A-Za-z_]\w*=/u;
|
|
1083
|
+
/**
|
|
1084
|
+
* Interpreters GitHub supports that this control deliberately leaves alone.
|
|
1085
|
+
* Named rather than inferred, so an unfamiliar command fails loudly instead of
|
|
1086
|
+
* being skipped as though it had been considered.
|
|
1087
|
+
*/
|
|
1088
|
+
const NON_SHELL_INTERPRETERS = new Set([
|
|
1089
|
+
"cmd",
|
|
1090
|
+
"powershell",
|
|
1091
|
+
"pwsh",
|
|
1092
|
+
"python",
|
|
1093
|
+
"python3"
|
|
1094
|
+
]);
|
|
1095
|
+
/**
|
|
1096
|
+
* The interpreter that will parse a step's script, or `undefined` for one this
|
|
1097
|
+
* control leaves alone.
|
|
1098
|
+
*
|
|
1099
|
+
* A step declaring no shell gets bash: that is GitHub's default for `run:` on
|
|
1100
|
+
* Linux runners. A step declaring `sh` gets `sh`, because the runner runs it
|
|
1101
|
+
* with `sh` — whose grammar is narrower than bash's, so parsing it with bash
|
|
1102
|
+
* would report a pass for a script the runner cannot run. Anything else
|
|
1103
|
+
* (pwsh, python, cmd) is not this control's business.
|
|
1104
|
+
*/
|
|
1105
|
+
const parserFor = (shell) => {
|
|
1106
|
+
if (shell === void 0) return ["bash"];
|
|
1107
|
+
const argv = [];
|
|
1108
|
+
let interpreter;
|
|
1109
|
+
for (const token of shell.trim().split(/\s+/u)) {
|
|
1110
|
+
if (token === "{0}") break;
|
|
1111
|
+
if (interpreter !== void 0) {
|
|
1112
|
+
argv.push(token);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const executable = token.slice(token.lastIndexOf("/") + 1);
|
|
1116
|
+
if (executable === "bash" || executable === "sh") interpreter = token;
|
|
1117
|
+
else if (!(executable === "env" || token.startsWith("-") || ASSIGNMENT.test(token))) {
|
|
1118
|
+
if (NON_SHELL_INTERPRETERS.has(executable)) return;
|
|
1119
|
+
throw new Error(`unrecognized \`shell:\` command: ${shell}. This control parses bash and sh scripts and knowingly skips pwsh, powershell, python, and cmd; it refuses rather than guess at anything else.`);
|
|
1120
|
+
}
|
|
1121
|
+
argv.push(token);
|
|
1122
|
+
}
|
|
1123
|
+
if (interpreter === void 0) throw new Error(`unrecognized \`shell:\` command: ${shell}. No interpreter to parse the script with.`);
|
|
1124
|
+
return argv;
|
|
1125
|
+
};
|
|
1126
|
+
/** Every `run:` block its interpreter refuses to parse. Empty means sound. */
|
|
1127
|
+
const workflowShellParseFailures = (yaml) => {
|
|
1128
|
+
const failures = [];
|
|
1129
|
+
for (const block of workflowRunBlocks(yaml)) {
|
|
1130
|
+
const parser = parserFor(block.shell);
|
|
1131
|
+
if (parser === void 0) continue;
|
|
1132
|
+
const [executable, ...parserArgs] = parser;
|
|
1133
|
+
const script = neutralizeExpressions(block.script);
|
|
1134
|
+
try {
|
|
1135
|
+
execFileSync(executable, [...parserArgs, "-n"], {
|
|
1136
|
+
input: script,
|
|
1137
|
+
stdio: [
|
|
1138
|
+
"pipe",
|
|
1139
|
+
"ignore",
|
|
1140
|
+
"pipe"
|
|
1141
|
+
]
|
|
1142
|
+
});
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
failures.push({
|
|
1145
|
+
script,
|
|
1146
|
+
...block.step === void 0 ? {} : { step: block.step },
|
|
1147
|
+
stderr: String(error.stderr ?? error).trim()
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return failures;
|
|
1152
|
+
};
|
|
1153
|
+
/**
|
|
1154
|
+
* Fail the generated-workflow lint when any embedded `run:` block is not
|
|
1155
|
+
* parseable bash. Call it on the YAML a generator is about to write, so the
|
|
1156
|
+
* defect is caught at generation rather than by the runner.
|
|
1157
|
+
*/
|
|
1158
|
+
const assertWorkflowShellParses = (yaml, options) => {
|
|
1159
|
+
const failures = workflowShellParseFailures(yaml);
|
|
1160
|
+
if (failures.length === 0) return;
|
|
1161
|
+
const detail = failures.map((failure) => ` - ${failure.step ?? "unnamed step"}: ${failure.stderr.replaceAll("\n", "\n ")}`).join("\n");
|
|
1162
|
+
throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
|
|
1163
|
+
};
|
|
1164
|
+
//#endregion
|
|
1165
|
+
export { 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, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, workflowRunBlocks, workflowShellParseFailures };
|