@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 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
- 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, 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 WorkflowStep, assertProofReuseCoverage, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands };
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 { spawnSync } from "node:child_process";
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
- 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, NODE_PNPM_ACTION_FAMILY_NODE24, assertProofReuseCoverage, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands };
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 };