@patronage/factory-ci 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patronage
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @patronage/factory-ci
2
+
3
+ The CI and deploy building blocks that already repeat across Patronage **factory projects** — projects that carry a `software-factory.profile.json`, deploy through Alchemy, and are subject to factory CI and proof gating.
4
+
5
+ ```bash
6
+ pnpm add -E @patronage/factory-ci
7
+ ```
8
+
9
+ It is a **pure library**. There is no `bin`: anything invocable belongs to `psf`. There is no `alchemy` or `effect` dependency of any kind — not a dependency, not a peer dependency — so the package can never pull a second copy of either into a consumer's graph. `esbuild` is its own dependency, which is what lets consumers delete their local arrangements for finding one.
10
+
11
+ ## What it is for
12
+
13
+ Three repositories independently built the same scaffolding and then drifted: three wordings of the same setup block, the same `@v4` action tags pinned to different commits because they were generated months apart, two esbuild pre-bundles, two Alchemy CLI wrappers, and one disposable-stage grammar invented twice.
14
+
15
+ The admission rule is **upstream on repetition**: nothing enters this package until it already repeats across at least two projects. Experiments live in one repository first. The public surface is made of three deep modules, not a workflow framework.
16
+
17
+ ## Exports
18
+
19
+ ### Workflow generation (gagen)
20
+
21
+ ```ts
22
+ import {
23
+ factoryWorkflow,
24
+ NODE_PNPM_ACTION_FAMILY_V4,
25
+ } from "@patronage/factory-ci";
26
+ ```
27
+
28
+ `factoryWorkflow(options)` owns one generated source's complete workflow-support artifact:
29
+
30
+ - `actions`: the explicit Node/pnpm action family plus every workflow-specific action pin
31
+ - `setupSteps`: checkout → pnpm → Node → install, customizable by named role
32
+ - `writeOptions`: the provenance banner and `pinDeps: false`, ready to spread into gagen's `writeOrLint`
33
+
34
+ Every action is validated as `owner/repo@<full sha>`, with its release tag kept separately. A complete `actionFamily` is required; there is no default that could silently move a consumer between action majors. `NODE_PNPM_ACTION_FAMILY_V4` is the family already proven across HQ's workflows. Firedup supplies its own complete v6 family until that family repeats in another project and qualifies for upstream admission.
35
+
36
+ ```ts
37
+ const generated = factoryWorkflow({
38
+ actionFamily: NODE_PNPM_ACTION_FAMILY_V4,
39
+ additionalActions: {
40
+ pathsFilter: {
41
+ tag: "v3",
42
+ uses: "dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590",
43
+ },
44
+ },
45
+ generated: {
46
+ source: ".github/workflows/verify.ts",
47
+ regenerate: "pnpm workflows:generate",
48
+ },
49
+ setup: {
50
+ setupNode: { cacheDependencyPath: "pnpm-lock.yaml" },
51
+ },
52
+ });
53
+
54
+ workflow({/* caller-owned jobs and topology */}).writeOrLint({
55
+ filePath,
56
+ ...generated.writeOptions,
57
+ });
58
+ ```
59
+
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
+
62
+ ### Alchemy entries
63
+
64
+ ```ts
65
+ import { bundleAlchemyEntry, executeAlchemyEntry } from "@patronage/factory-ci";
66
+ ```
67
+
68
+ `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.
69
+
70
+ `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.
71
+
72
+ The caller still owns deploy/destroy/plan arguments, stage admission, credentials, environment shaping, destructive-plan guards, and convergence. Failure messages never repeat arguments or environment values.
73
+
74
+ ### Disposable stages
75
+
76
+ ```ts
77
+ import {
78
+ isLocalPreviewStage,
79
+ localPreviewStage,
80
+ parseLocalPreviewStage,
81
+ } from "@patronage/factory-ci";
82
+ ```
83
+
84
+ `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.
85
+
86
+ ### Published-package contract
87
+
88
+ 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.
89
+
90
+ ## No configuration surface
91
+
92
+ Functions take plain typed options objects. Proof and preview configuration lives in the canonical `software-factory.profile.json` schema, never in this package (ADR 0021); mapping a profile into these options is the profile's job.
93
+
94
+ ## Secrets
95
+
96
+ **This package introduces no GitHub-Actions-held secrets, and none may be added to it.**
97
+
98
+ The credential env-block helper that the audit found repeated was deliberately cut on design grounds: shipping it would have entrenched Actions-held secrets across every project that adopts this package. The standing direction is that credentials resolve from Cloudflare, minimized toward the HQ Worker's documented posture — the floor being a single `CLOUDFLARE_API_TOKEN` where a deploy genuinely must run from Actions, because Cloudflare has no Actions OIDC today. This constraint binds later phases of the package, not just its first release.
99
+
100
+ ## Releases
101
+
102
+ Attended and hand-cut, on the same terms as `@patronage/alchemy-d1-state`: bump the version, run the workspace checks, read the `npm pack --dry-run` file list, publish, tag `factory-ci@<version>`. There is no changesets setup and no automatic release trigger, by design. Consumers pin exact versions.
103
+
104
+ ## License
105
+
106
+ MIT.
@@ -0,0 +1,228 @@
1
+ //#region src/actions.d.ts
2
+ /**
3
+ * The GitHub Actions this family's workflow generators use, pinned to full
4
+ * commit SHAs in one place (#268).
5
+ *
6
+ * Every repository pinned the same *tags* and got different *commits*: this
7
+ * repository's `verify.yml` carried `actions/checkout@11d5960a` while
8
+ * `hq-deploy.yml` and all of paitronage carried `34e11487`, because gagen
9
+ * resolves a floating `@v4` at generation time and the files were generated
10
+ * months apart. Naming the SHA here is what makes that class of drift
11
+ * impossible: regenerating a workflow can no longer move a pin.
12
+ *
13
+ * `uses` is a bare `owner/repo@<sha>` string, safe to hand straight to a
14
+ * gagen step. The tag lives in its own field rather than as a `# v4.4.0`
15
+ * suffix on `uses`, because a `#` inside a YAML scalar forces quoting and
16
+ * gagen's pin pass reads the value with `\S+` — the two together corrupt the
17
+ * emitted `uses:` line. gagen writes the `# <tag>` comment itself.
18
+ */
19
+ interface PinnedAction {
20
+ /** `owner/repo@<40-char commit sha>`, ready to use as a step's `uses`. */
21
+ readonly uses: string;
22
+ /** The release tag that commit carries, for humans and for bump tooling. */
23
+ readonly tag: string;
24
+ }
25
+ interface NodePnpmActionFamily {
26
+ /** Review-visible identity for the complete, compatible action family. */
27
+ readonly id: string;
28
+ readonly checkout: PinnedAction;
29
+ readonly setupNode: PinnedAction;
30
+ readonly setupPnpm: PinnedAction;
31
+ }
32
+ /**
33
+ * The v4 family already shared by HQ's generated workflows.
34
+ *
35
+ * Consumers must pass this (or their own complete family) to
36
+ * `factoryWorkflow`; there is deliberately no implicit default. In
37
+ * particular, Firedup's v6 action family must stay v6 during adoption.
38
+ */
39
+ declare const NODE_PNPM_ACTION_FAMILY_V4: {
40
+ readonly checkout: {
41
+ readonly tag: "v4.4.0";
42
+ readonly uses: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262";
43
+ };
44
+ readonly id: "node-pnpm-v4";
45
+ readonly setupNode: {
46
+ readonly tag: "v4.4.0";
47
+ readonly uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020";
48
+ };
49
+ readonly setupPnpm: {
50
+ readonly tag: "v4.3.0";
51
+ readonly uses: "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1";
52
+ };
53
+ };
54
+ //#endregion
55
+ //#region src/bundle-alchemy-entry.d.ts
56
+ interface BundleAlchemyEntryOptions {
57
+ /** The Alchemy entry to bundle, e.g. `alchemy.run.ts`. */
58
+ readonly entry: string;
59
+ /** Where to write the bundled ESM file, e.g. `.alchemy/deploy-entry.mjs`. */
60
+ readonly outfile: string;
61
+ /** esbuild's working directory; also the base for relative paths. */
62
+ readonly absWorkingDir?: string;
63
+ /**
64
+ * `"external"` (default) leaves every bare import outside the entry's own
65
+ * source graph to Node's resolver at run time — the entry's TypeScript is
66
+ * still inlined, which is the whole point. `"bundle"` inlines dependencies
67
+ * too, minus the Alchemy/Effect externals, for a runner without the
68
+ * consumer's node_modules.
69
+ */
70
+ readonly packages?: "bundle" | "external";
71
+ /** Emit a sourcemap next to the outfile. Default `false`. */
72
+ readonly sourcemap?: boolean;
73
+ /** esbuild target. Default `node24`. */
74
+ readonly target?: string;
75
+ /** tsconfig to apply to the entry's sources. */
76
+ readonly tsconfig?: string;
77
+ }
78
+ /**
79
+ * Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
80
+ * `effect` external (#268).
81
+ *
82
+ * Both repos that deploy through Alchemy wrote this: the Alchemy CLI runs a
83
+ * JavaScript entry, so a TypeScript `alchemy.run.ts` has to be flattened
84
+ * first, and the flattening must not swallow the two packages whose identity
85
+ * matters. Firedup additionally resolved esbuild *through vite* because it had
86
+ * no direct dependency on it; here esbuild is a plain dependency of this
87
+ * package and that hack can be deleted.
88
+ *
89
+ * Returns the absolute path of the file written.
90
+ */
91
+ declare const bundleAlchemyEntry: (options: BundleAlchemyEntryOptions) => Promise<string>;
92
+ //#endregion
93
+ //#region src/disposable-stage.d.ts
94
+ declare const localPreviewStageBrand: unique symbol;
95
+ type LocalPreviewStage = string & {
96
+ readonly [localPreviewStageBrand]: true;
97
+ };
98
+ interface ParsedLocalPreviewStage {
99
+ readonly headShaPrefix: string;
100
+ readonly pr: number;
101
+ readonly stage: LocalPreviewStage;
102
+ }
103
+ interface ParseLocalPreviewStageExpected {
104
+ /** Require the stage to belong to this pull request. */
105
+ readonly pr?: number;
106
+ /** Require the stage prefix to match this full 40-character head SHA. */
107
+ readonly headSha?: string;
108
+ }
109
+ /**
110
+ * Interpret an untrusted stage name through the one disposable-stage grammar.
111
+ * Returns `null` rather than throwing when the value or expected ownership
112
+ * does not match.
113
+ */
114
+ declare const parseLocalPreviewStage: (stage: string, expected?: ParseLocalPreviewStageExpected) => ParsedLocalPreviewStage | null;
115
+ /**
116
+ * Whether `stage` is disposable. Pass `pr` to also require PR ownership.
117
+ */
118
+ declare const isLocalPreviewStage: (stage: string, pr?: number) => stage is LocalPreviewStage;
119
+ interface LocalPreviewStageOptions {
120
+ /** The pull request number. */
121
+ readonly pr: number;
122
+ /** The full head SHA the preview is tied to. */
123
+ readonly headSha: string;
124
+ /** How much of the SHA to keep. Default 12; the grammar allows 7 to 40. */
125
+ readonly shaLength?: number;
126
+ }
127
+ /**
128
+ * Build the disposable stage name for a pull request head.
129
+ *
130
+ * Throws when the inputs cannot produce a valid stage, so a caller can never
131
+ * deploy into a name the cleanup sweep will not recognise.
132
+ */
133
+ declare const localPreviewStage: (options: LocalPreviewStageOptions) => LocalPreviewStage;
134
+ //#endregion
135
+ //#region src/factory-workflow.d.ts
136
+ interface WorkflowStep {
137
+ readonly name: string;
138
+ readonly uses?: string;
139
+ readonly with?: Readonly<Record<string, string>>;
140
+ readonly run?: string;
141
+ }
142
+ interface CheckoutStepOptions {
143
+ readonly name?: string;
144
+ readonly ref?: string;
145
+ }
146
+ interface SetupNodeStepOptions {
147
+ readonly cacheDependencyPath?: string;
148
+ readonly nodeVersionFile?: string;
149
+ }
150
+ interface InstallStepOptions {
151
+ readonly name?: string;
152
+ readonly run?: string;
153
+ }
154
+ interface FactoryWorkflowSetupOptions {
155
+ readonly checkout?: CheckoutStepOptions;
156
+ readonly install?: InstallStepOptions;
157
+ readonly setupNode?: SetupNodeStepOptions;
158
+ }
159
+ interface FactoryWorkflowOptions<Additional extends Readonly<Record<string, PinnedAction>>> {
160
+ /**
161
+ * Required complete family. There is no default, so adopting this package
162
+ * cannot silently move a consumer from one action major to another.
163
+ */
164
+ readonly actionFamily: NodePnpmActionFamily;
165
+ /** Every workflow-specific action source this artifact owns. */
166
+ readonly additionalActions?: Additional;
167
+ readonly generated: {
168
+ /** Generator path relative to the repository root. */readonly source: string; /** Exact command that regenerates the emitted workflow. */
169
+ readonly regenerate: string;
170
+ };
171
+ readonly setup?: FactoryWorkflowSetupOptions;
172
+ }
173
+ type FamilyActions = Omit<NodePnpmActionFamily, "id">;
174
+ interface FactoryWorkflowArtifact<Additional extends Readonly<Record<string, PinnedAction>>> {
175
+ readonly actionFamilyId: string;
176
+ readonly actions: Readonly<FamilyActions & Additional>;
177
+ /** Checkout → pnpm → Node → install, customized by named role. */
178
+ readonly setupSteps: readonly WorkflowStep[];
179
+ /**
180
+ * Structurally accepted by gagen's `writeOrLint`; this package deliberately
181
+ * has no gagen dependency.
182
+ */
183
+ readonly writeOptions: {
184
+ readonly header: string;
185
+ readonly pinDeps: false;
186
+ };
187
+ }
188
+ /**
189
+ * Own one generated workflow source's action pins, setup block, provenance
190
+ * banner, and gagen pinning policy as a single artifact.
191
+ *
192
+ * The caller still owns workflow topology, jobs, runners, permissions, and
193
+ * every deployment policy decision.
194
+ */
195
+ declare const factoryWorkflow: <const Additional extends Readonly<Record<string, PinnedAction>> = Record<never, never>>(options: FactoryWorkflowOptions<Additional>) => FactoryWorkflowArtifact<Additional>;
196
+ //#endregion
197
+ //#region src/execute-alchemy-entry.d.ts
198
+ interface ExecuteAlchemyEntryOptions {
199
+ /**
200
+ * CLI arguments before the bundled entry. The absolute entry path is always
201
+ * appended as the final argument.
202
+ */
203
+ readonly args: readonly string[];
204
+ readonly bundle: BundleAlchemyEntryOptions;
205
+ /** Child working directory. Defaults to the bundle root, then process.cwd. */
206
+ readonly cwd?: string;
207
+ /** Child environment. Defaults to process.env. */
208
+ readonly env?: NodeJS.ProcessEnv;
209
+ /** Consumer module context; normally the caller's import.meta.url. */
210
+ readonly from: string | URL;
211
+ /** Child stdio. Default "inherit". */
212
+ readonly stdio?: "inherit" | "pipe";
213
+ }
214
+ interface ExecuteAlchemyEntryResult {
215
+ readonly bundledEntry: string;
216
+ readonly stderr: string | null;
217
+ readonly stdout: string | null;
218
+ }
219
+ /**
220
+ * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
221
+ * run it under the current Node binary, and return only on a successful exit.
222
+ *
223
+ * Deploy/destroy/plan selection, stage admission, credentials, and every
224
+ * convergence or destructive-change policy remain the caller's responsibility.
225
+ */
226
+ declare const executeAlchemyEntry: (options: ExecuteAlchemyEntryOptions) => Promise<ExecuteAlchemyEntryResult>;
227
+ //#endregion
228
+ export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_V4, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type SetupNodeStepOptions, type WorkflowStep, bundleAlchemyEntry, executeAlchemyEntry, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage };
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { build } from "esbuild";
5
+ import { spawnSync } from "node:child_process";
6
+ //#region src/actions.ts
7
+ /**
8
+ * The v4 family already shared by HQ's generated workflows.
9
+ *
10
+ * Consumers must pass this (or their own complete family) to
11
+ * `factoryWorkflow`; there is deliberately no implicit default. In
12
+ * particular, Firedup's v6 action family must stay v6 during adoption.
13
+ */
14
+ const NODE_PNPM_ACTION_FAMILY_V4 = {
15
+ checkout: {
16
+ tag: "v4.4.0",
17
+ uses: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262"
18
+ },
19
+ id: "node-pnpm-v4",
20
+ setupNode: {
21
+ tag: "v4.4.0",
22
+ uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020"
23
+ },
24
+ setupPnpm: {
25
+ tag: "v4.3.0",
26
+ uses: "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1"
27
+ }
28
+ };
29
+ //#endregion
30
+ //#region src/bundle-alchemy-entry.ts
31
+ /**
32
+ * Alchemy and Effect must resolve from the *consumer's* node_modules at run
33
+ * time, never from the bundle: Alchemy's resource registry and Effect's
34
+ * service tags are identity-sensitive, and a second copy silently breaks both.
35
+ */
36
+ const ALCHEMY_EXTERNALS = [
37
+ "alchemy",
38
+ "alchemy/*",
39
+ "effect",
40
+ "effect/*"
41
+ ];
42
+ /**
43
+ * Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
44
+ * `effect` external (#268).
45
+ *
46
+ * Both repos that deploy through Alchemy wrote this: the Alchemy CLI runs a
47
+ * JavaScript entry, so a TypeScript `alchemy.run.ts` has to be flattened
48
+ * first, and the flattening must not swallow the two packages whose identity
49
+ * matters. Firedup additionally resolved esbuild *through vite* because it had
50
+ * no direct dependency on it; here esbuild is a plain dependency of this
51
+ * package and that hack can be deleted.
52
+ *
53
+ * Returns the absolute path of the file written.
54
+ */
55
+ const bundleAlchemyEntry = async (options) => {
56
+ const root = options.absWorkingDir ? path.resolve(options.absWorkingDir) : process.cwd();
57
+ const outfile = path.resolve(root, options.outfile);
58
+ await mkdir(path.dirname(outfile), { recursive: true });
59
+ await build({
60
+ absWorkingDir: root,
61
+ bundle: true,
62
+ entryPoints: [path.resolve(root, options.entry)],
63
+ external: ALCHEMY_EXTERNALS,
64
+ format: "esm",
65
+ outfile,
66
+ packages: options.packages ?? "external",
67
+ platform: "node",
68
+ sourcemap: options.sourcemap ?? false,
69
+ target: options.target ?? "node24",
70
+ ...options.tsconfig ? { tsconfig: path.resolve(root, options.tsconfig) } : {}
71
+ });
72
+ return outfile;
73
+ };
74
+ //#endregion
75
+ //#region src/disposable-stage.ts
76
+ /**
77
+ * The disposable Alchemy stage a local preview deploy runs in:
78
+ * `local-pr-<pr>-<short sha>` (#268).
79
+ *
80
+ * Two repos invented this grammar independently and then had to agree on it
81
+ * anyway, because the same cleanup sweeps delete these stages. A stage that
82
+ * does not match is not disposable, and destructive operations must refuse it.
83
+ */
84
+ const LOCAL_PREVIEW_STAGE_PATTERN = /^local-pr-(?<pr>[1-9]\d*)-(?<sha>[0-9a-f]{7,40})$/u;
85
+ /**
86
+ * Interpret an untrusted stage name through the one disposable-stage grammar.
87
+ * Returns `null` rather than throwing when the value or expected ownership
88
+ * does not match.
89
+ */
90
+ const parseLocalPreviewStage = (stage, expected = {}) => {
91
+ const match = LOCAL_PREVIEW_STAGE_PATTERN.exec(stage);
92
+ if (!match?.groups) return null;
93
+ const pr = Number(match.groups.pr);
94
+ const headShaPrefix = match.groups.sha;
95
+ if (!Number.isSafeInteger(pr) || expected.pr !== void 0 && pr !== expected.pr) return null;
96
+ if (expected.headSha !== void 0) {
97
+ const headSha = expected.headSha.trim().toLowerCase();
98
+ if (!/^[0-9a-f]{40}$/u.test(headSha) || !headSha.startsWith(headShaPrefix)) return null;
99
+ }
100
+ return {
101
+ headShaPrefix,
102
+ pr,
103
+ stage
104
+ };
105
+ };
106
+ /**
107
+ * Whether `stage` is disposable. Pass `pr` to also require PR ownership.
108
+ */
109
+ const isLocalPreviewStage = (stage, pr) => parseLocalPreviewStage(stage, pr === void 0 ? {} : { pr }) !== null;
110
+ /**
111
+ * Build the disposable stage name for a pull request head.
112
+ *
113
+ * Throws when the inputs cannot produce a valid stage, so a caller can never
114
+ * deploy into a name the cleanup sweep will not recognise.
115
+ */
116
+ const localPreviewStage = (options) => {
117
+ const { headSha, pr, shaLength = 12 } = options;
118
+ if (!Number.isSafeInteger(pr) || pr < 1) throw new Error(`pr must be a positive safe integer, got ${pr}.`);
119
+ if (!Number.isInteger(shaLength) || shaLength < 7 || shaLength > 40) throw new Error(`shaLength must be an integer from 7 through 40, got ${shaLength}.`);
120
+ const normalizedHead = headSha.trim().toLowerCase();
121
+ if (!/^[0-9a-f]{40}$/u.test(normalizedHead)) throw new Error(`headSha must be exactly 40 hexadecimal characters, got "${headSha}".`);
122
+ return `local-pr-${pr}-${normalizedHead.slice(0, shaLength)}`;
123
+ };
124
+ //#endregion
125
+ //#region src/factory-workflow.ts
126
+ const PINNED_ACTION_PATTERN = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/u;
127
+ const ACTION_TAG_PATTERN = /^v\d+(?:\.\d+){0,2}$/u;
128
+ const RESERVED_ACTION_NAMES = new Set([
129
+ "checkout",
130
+ "setupNode",
131
+ "setupPnpm"
132
+ ]);
133
+ const assertSingleLine = (name, value) => {
134
+ if (value.trim() !== value || value.length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} must be a single non-empty line.`);
135
+ };
136
+ const actionRepository = (action) => action.uses.slice(0, action.uses.indexOf("@"));
137
+ const assertPinnedAction = (name, action, expectedRepository) => {
138
+ if (!PINNED_ACTION_PATTERN.test(action.uses)) throw new Error(`${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`);
139
+ if (expectedRepository && actionRepository(action) !== expectedRepository) throw new Error(`${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`);
140
+ if (!ACTION_TAG_PATTERN.test(action.tag)) throw new Error(`${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`);
141
+ };
142
+ const usesStep = (fallbackName, action) => ({
143
+ name: fallbackName,
144
+ uses: action.uses
145
+ });
146
+ const assertActionFamily = (family) => {
147
+ assertSingleLine("actionFamily.id", family.id);
148
+ assertPinnedAction("checkout", family.checkout, "actions/checkout");
149
+ assertPinnedAction("setupNode", family.setupNode, "actions/setup-node");
150
+ assertPinnedAction("setupPnpm", family.setupPnpm, "pnpm/action-setup");
151
+ };
152
+ const assertAdditionalActions = (actions) => {
153
+ for (const [name, action] of Object.entries(actions)) {
154
+ if (RESERVED_ACTION_NAMES.has(name)) throw new Error(`${name} is reserved by the Node/pnpm action family and cannot be replaced by additionalActions.`);
155
+ assertPinnedAction(name, action);
156
+ }
157
+ };
158
+ const setupSteps = (family, setup = {}) => {
159
+ const installRun = setup.install?.run ?? "pnpm install --frozen-lockfile";
160
+ if (/(?:^|\s)--ignore-scripts(?:\s|$)/u.test(installRun)) throw new Error("The install step must not use --ignore-scripts; workspace prepare scripts build required package artifacts.");
161
+ const { checkout, setupNode } = setup;
162
+ return Object.freeze([
163
+ {
164
+ name: checkout?.name ?? "Checkout",
165
+ uses: family.checkout.uses,
166
+ ...checkout?.ref ? { with: { ref: checkout.ref } } : {}
167
+ },
168
+ usesStep("Setup pnpm", family.setupPnpm),
169
+ {
170
+ name: "Setup Node",
171
+ uses: family.setupNode.uses,
172
+ with: {
173
+ cache: "pnpm",
174
+ ...setupNode?.cacheDependencyPath ? { "cache-dependency-path": setupNode.cacheDependencyPath } : {},
175
+ "node-version-file": setupNode?.nodeVersionFile ?? ".nvmrc"
176
+ }
177
+ },
178
+ {
179
+ name: setup.install?.name ?? "Install workspace",
180
+ run: installRun
181
+ }
182
+ ]);
183
+ };
184
+ /**
185
+ * Own one generated workflow source's action pins, setup block, provenance
186
+ * banner, and gagen pinning policy as a single artifact.
187
+ *
188
+ * The caller still owns workflow topology, jobs, runners, permissions, and
189
+ * every deployment policy decision.
190
+ */
191
+ const factoryWorkflow = (options) => {
192
+ assertSingleLine("generated.source", options.generated.source);
193
+ assertSingleLine("generated.regenerate", options.generated.regenerate);
194
+ assertActionFamily(options.actionFamily);
195
+ assertAdditionalActions(options.additionalActions ?? {});
196
+ const actions = Object.freeze({
197
+ checkout: options.actionFamily.checkout,
198
+ setupNode: options.actionFamily.setupNode,
199
+ setupPnpm: options.actionFamily.setupPnpm,
200
+ ...options.additionalActions ?? {}
201
+ });
202
+ return Object.freeze({
203
+ actionFamilyId: options.actionFamily.id,
204
+ actions,
205
+ setupSteps: setupSteps(options.actionFamily, options.setup),
206
+ writeOptions: Object.freeze({
207
+ header: [`# GENERATED BY ${options.generated.source} -- DO NOT EDIT.`, `# Regenerate: ${options.generated.regenerate}`].join("\n"),
208
+ pinDeps: false
209
+ })
210
+ });
211
+ };
212
+ //#endregion
213
+ //#region src/execute-alchemy-entry.ts
214
+ /**
215
+ * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
216
+ * run it under the current Node binary, and return only on a successful exit.
217
+ *
218
+ * Deploy/destroy/plan selection, stage admission, credentials, and every
219
+ * convergence or destructive-change policy remain the caller's responsibility.
220
+ */
221
+ const executeAlchemyEntry = async (options) => {
222
+ const cliEntry = createRequire(options.from).resolve("alchemy/bin/alchemy.js");
223
+ const bundledEntry = await bundleAlchemyEntry(options.bundle);
224
+ const result = spawnSync(process.execPath, [
225
+ cliEntry,
226
+ ...options.args,
227
+ bundledEntry
228
+ ], {
229
+ cwd: options.cwd ?? options.bundle.absWorkingDir ?? process.cwd(),
230
+ encoding: "utf-8",
231
+ env: options.env ?? process.env,
232
+ stdio: options.stdio ?? "inherit"
233
+ });
234
+ if (result.error) throw new Error("Failed to start the Alchemy CLI.");
235
+ if (result.signal) throw new Error(`Alchemy CLI terminated by signal ${result.signal}.`);
236
+ if (result.status !== 0) throw new Error(result.status === null ? "Alchemy CLI exited without reporting a status." : `Alchemy CLI exited with status ${result.status}.`);
237
+ return {
238
+ bundledEntry,
239
+ stderr: result.stderr,
240
+ stdout: result.stdout
241
+ };
242
+ };
243
+ //#endregion
244
+ export { NODE_PNPM_ACTION_FAMILY_V4, bundleAlchemyEntry, executeAlchemyEntry, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@patronage/factory-ci",
3
+ "version": "0.1.1",
4
+ "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, Alchemy entry execution, and disposable-stage semantics",
5
+ "keywords": [
6
+ "alchemy",
7
+ "cloudflare",
8
+ "continuous-integration",
9
+ "esbuild",
10
+ "github-actions"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/patronage/software-factory.git",
16
+ "directory": "factory-ci"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "development": {
26
+ "types": "./src/index.ts",
27
+ "default": "./src/index.ts"
28
+ },
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "esbuild": "0.28.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "24.13.3",
41
+ "oxfmt": "0.59.0",
42
+ "oxlint": "1.74.0",
43
+ "tsdown": "0.21.10",
44
+ "typescript": "5.9.3",
45
+ "ultracite": "7.9.4",
46
+ "vitest": "4.1.10"
47
+ },
48
+ "engines": {
49
+ "node": "^24.0.0"
50
+ },
51
+ "scripts": {
52
+ "prebuild": "bash ../scripts/ensure-worktree-bootstrap.sh",
53
+ "build": "tsdown",
54
+ "precheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
55
+ "check": "ultracite check",
56
+ "prefix": "bash ../scripts/ensure-worktree-bootstrap.sh",
57
+ "fix": "ultracite fix",
58
+ "pretest": "bash ../scripts/ensure-worktree-bootstrap.sh",
59
+ "test": "vitest run",
60
+ "pretypecheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
61
+ "typecheck": "tsc --noEmit"
62
+ }
63
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The GitHub Actions this family's workflow generators use, pinned to full
3
+ * commit SHAs in one place (#268).
4
+ *
5
+ * Every repository pinned the same *tags* and got different *commits*: this
6
+ * repository's `verify.yml` carried `actions/checkout@11d5960a` while
7
+ * `hq-deploy.yml` and all of paitronage carried `34e11487`, because gagen
8
+ * resolves a floating `@v4` at generation time and the files were generated
9
+ * months apart. Naming the SHA here is what makes that class of drift
10
+ * impossible: regenerating a workflow can no longer move a pin.
11
+ *
12
+ * `uses` is a bare `owner/repo@<sha>` string, safe to hand straight to a
13
+ * gagen step. The tag lives in its own field rather than as a `# v4.4.0`
14
+ * suffix on `uses`, because a `#` inside a YAML scalar forces quoting and
15
+ * gagen's pin pass reads the value with `\S+` — the two together corrupt the
16
+ * emitted `uses:` line. gagen writes the `# <tag>` comment itself.
17
+ */
18
+ export interface PinnedAction {
19
+ /** `owner/repo@<40-char commit sha>`, ready to use as a step's `uses`. */
20
+ readonly uses: string;
21
+ /** The release tag that commit carries, for humans and for bump tooling. */
22
+ readonly tag: string;
23
+ }
24
+
25
+ export interface NodePnpmActionFamily {
26
+ /** Review-visible identity for the complete, compatible action family. */
27
+ readonly id: string;
28
+ readonly checkout: PinnedAction;
29
+ readonly setupNode: PinnedAction;
30
+ readonly setupPnpm: PinnedAction;
31
+ }
32
+
33
+ /**
34
+ * The v4 family already shared by HQ's generated workflows.
35
+ *
36
+ * Consumers must pass this (or their own complete family) to
37
+ * `factoryWorkflow`; there is deliberately no implicit default. In
38
+ * particular, Firedup's v6 action family must stay v6 during adoption.
39
+ */
40
+ export const NODE_PNPM_ACTION_FAMILY_V4 = {
41
+ checkout: {
42
+ tag: "v4.4.0",
43
+ uses: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
44
+ },
45
+ id: "node-pnpm-v4",
46
+ setupNode: {
47
+ tag: "v4.4.0",
48
+ uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020",
49
+ },
50
+ setupPnpm: {
51
+ tag: "v4.3.0",
52
+ uses: "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1",
53
+ },
54
+ } as const satisfies NodePnpmActionFamily;
@@ -0,0 +1,76 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { build } from "esbuild";
5
+
6
+ /**
7
+ * Alchemy and Effect must resolve from the *consumer's* node_modules at run
8
+ * time, never from the bundle: Alchemy's resource registry and Effect's
9
+ * service tags are identity-sensitive, and a second copy silently breaks both.
10
+ */
11
+ const ALCHEMY_EXTERNALS = ["alchemy", "alchemy/*", "effect", "effect/*"];
12
+
13
+ export interface BundleAlchemyEntryOptions {
14
+ /** The Alchemy entry to bundle, e.g. `alchemy.run.ts`. */
15
+ readonly entry: string;
16
+ /** Where to write the bundled ESM file, e.g. `.alchemy/deploy-entry.mjs`. */
17
+ readonly outfile: string;
18
+ /** esbuild's working directory; also the base for relative paths. */
19
+ readonly absWorkingDir?: string;
20
+ /**
21
+ * `"external"` (default) leaves every bare import outside the entry's own
22
+ * source graph to Node's resolver at run time — the entry's TypeScript is
23
+ * still inlined, which is the whole point. `"bundle"` inlines dependencies
24
+ * too, minus the Alchemy/Effect externals, for a runner without the
25
+ * consumer's node_modules.
26
+ */
27
+ readonly packages?: "bundle" | "external";
28
+ /** Emit a sourcemap next to the outfile. Default `false`. */
29
+ readonly sourcemap?: boolean;
30
+ /** esbuild target. Default `node24`. */
31
+ readonly target?: string;
32
+ /** tsconfig to apply to the entry's sources. */
33
+ readonly tsconfig?: string;
34
+ }
35
+
36
+ /**
37
+ * Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
38
+ * `effect` external (#268).
39
+ *
40
+ * Both repos that deploy through Alchemy wrote this: the Alchemy CLI runs a
41
+ * JavaScript entry, so a TypeScript `alchemy.run.ts` has to be flattened
42
+ * first, and the flattening must not swallow the two packages whose identity
43
+ * matters. Firedup additionally resolved esbuild *through vite* because it had
44
+ * no direct dependency on it; here esbuild is a plain dependency of this
45
+ * package and that hack can be deleted.
46
+ *
47
+ * Returns the absolute path of the file written.
48
+ */
49
+ export const bundleAlchemyEntry = async (
50
+ options: BundleAlchemyEntryOptions
51
+ ): Promise<string> => {
52
+ const root = options.absWorkingDir
53
+ ? path.resolve(options.absWorkingDir)
54
+ : process.cwd();
55
+ const outfile = path.resolve(root, options.outfile);
56
+
57
+ await mkdir(path.dirname(outfile), { recursive: true });
58
+
59
+ await build({
60
+ absWorkingDir: root,
61
+ bundle: true,
62
+ entryPoints: [path.resolve(root, options.entry)],
63
+ external: ALCHEMY_EXTERNALS,
64
+ format: "esm",
65
+ outfile,
66
+ packages: options.packages ?? "external",
67
+ platform: "node",
68
+ sourcemap: options.sourcemap ?? false,
69
+ target: options.target ?? "node24",
70
+ ...(options.tsconfig
71
+ ? { tsconfig: path.resolve(root, options.tsconfig) }
72
+ : {}),
73
+ });
74
+
75
+ return outfile;
76
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The disposable Alchemy stage a local preview deploy runs in:
3
+ * `local-pr-<pr>-<short sha>` (#268).
4
+ *
5
+ * Two repos invented this grammar independently and then had to agree on it
6
+ * anyway, because the same cleanup sweeps delete these stages. A stage that
7
+ * does not match is not disposable, and destructive operations must refuse it.
8
+ */
9
+ const LOCAL_PREVIEW_STAGE_PATTERN =
10
+ /^local-pr-(?<pr>[1-9]\d*)-(?<sha>[0-9a-f]{7,40})$/u;
11
+
12
+ declare const localPreviewStageBrand: unique symbol;
13
+
14
+ export type LocalPreviewStage = string & {
15
+ readonly [localPreviewStageBrand]: true;
16
+ };
17
+
18
+ export interface ParsedLocalPreviewStage {
19
+ readonly headShaPrefix: string;
20
+ readonly pr: number;
21
+ readonly stage: LocalPreviewStage;
22
+ }
23
+
24
+ export interface ParseLocalPreviewStageExpected {
25
+ /** Require the stage to belong to this pull request. */
26
+ readonly pr?: number;
27
+ /** Require the stage prefix to match this full 40-character head SHA. */
28
+ readonly headSha?: string;
29
+ }
30
+
31
+ /**
32
+ * Interpret an untrusted stage name through the one disposable-stage grammar.
33
+ * Returns `null` rather than throwing when the value or expected ownership
34
+ * does not match.
35
+ */
36
+ export const parseLocalPreviewStage = (
37
+ stage: string,
38
+ expected: ParseLocalPreviewStageExpected = {}
39
+ ): ParsedLocalPreviewStage | null => {
40
+ const match = LOCAL_PREVIEW_STAGE_PATTERN.exec(stage);
41
+ if (!match?.groups) {
42
+ return null;
43
+ }
44
+
45
+ const pr = Number(match.groups.pr);
46
+ const headShaPrefix = match.groups.sha;
47
+ if (
48
+ !Number.isSafeInteger(pr) ||
49
+ (expected.pr !== undefined && pr !== expected.pr)
50
+ ) {
51
+ return null;
52
+ }
53
+ if (expected.headSha !== undefined) {
54
+ const headSha = expected.headSha.trim().toLowerCase();
55
+ if (
56
+ !/^[0-9a-f]{40}$/u.test(headSha) ||
57
+ !headSha.startsWith(headShaPrefix)
58
+ ) {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ return {
64
+ headShaPrefix,
65
+ pr,
66
+ stage: stage as LocalPreviewStage,
67
+ };
68
+ };
69
+
70
+ /**
71
+ * Whether `stage` is disposable. Pass `pr` to also require PR ownership.
72
+ */
73
+ export const isLocalPreviewStage = (
74
+ stage: string,
75
+ pr?: number
76
+ ): stage is LocalPreviewStage =>
77
+ parseLocalPreviewStage(stage, pr === undefined ? {} : { pr }) !== null;
78
+
79
+ export interface LocalPreviewStageOptions {
80
+ /** The pull request number. */
81
+ readonly pr: number;
82
+ /** The full head SHA the preview is tied to. */
83
+ readonly headSha: string;
84
+ /** How much of the SHA to keep. Default 12; the grammar allows 7 to 40. */
85
+ readonly shaLength?: number;
86
+ }
87
+
88
+ /**
89
+ * Build the disposable stage name for a pull request head.
90
+ *
91
+ * Throws when the inputs cannot produce a valid stage, so a caller can never
92
+ * deploy into a name the cleanup sweep will not recognise.
93
+ */
94
+ export const localPreviewStage = (
95
+ options: LocalPreviewStageOptions
96
+ ): LocalPreviewStage => {
97
+ const { headSha, pr, shaLength = 12 } = options;
98
+ if (!Number.isSafeInteger(pr) || pr < 1) {
99
+ throw new Error(`pr must be a positive safe integer, got ${pr}.`);
100
+ }
101
+ if (!Number.isInteger(shaLength) || shaLength < 7 || shaLength > 40) {
102
+ throw new Error(
103
+ `shaLength must be an integer from 7 through 40, got ${shaLength}.`
104
+ );
105
+ }
106
+
107
+ const normalizedHead = headSha.trim().toLowerCase();
108
+ if (!/^[0-9a-f]{40}$/u.test(normalizedHead)) {
109
+ throw new Error(
110
+ `headSha must be exactly 40 hexadecimal characters, got "${headSha}".`
111
+ );
112
+ }
113
+
114
+ return `local-pr-${pr}-${normalizedHead.slice(0, shaLength)}` as LocalPreviewStage;
115
+ };
@@ -0,0 +1,78 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+
4
+ import type { BundleAlchemyEntryOptions } from "./bundle-alchemy-entry.ts";
5
+ import { bundleAlchemyEntry } from "./bundle-alchemy-entry.ts";
6
+
7
+ export interface ExecuteAlchemyEntryOptions {
8
+ /**
9
+ * CLI arguments before the bundled entry. The absolute entry path is always
10
+ * appended as the final argument.
11
+ */
12
+ readonly args: readonly string[];
13
+ readonly bundle: BundleAlchemyEntryOptions;
14
+ /** Child working directory. Defaults to the bundle root, then process.cwd. */
15
+ readonly cwd?: string;
16
+ /** Child environment. Defaults to process.env. */
17
+ readonly env?: NodeJS.ProcessEnv;
18
+ /** Consumer module context; normally the caller's import.meta.url. */
19
+ readonly from: string | URL;
20
+ /** Child stdio. Default "inherit". */
21
+ readonly stdio?: "inherit" | "pipe";
22
+ }
23
+
24
+ export interface ExecuteAlchemyEntryResult {
25
+ readonly bundledEntry: string;
26
+ readonly stderr: string | null;
27
+ readonly stdout: string | null;
28
+ }
29
+
30
+ /**
31
+ * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
32
+ * run it under the current Node binary, and return only on a successful exit.
33
+ *
34
+ * Deploy/destroy/plan selection, stage admission, credentials, and every
35
+ * convergence or destructive-change policy remain the caller's responsibility.
36
+ */
37
+ export const executeAlchemyEntry = async (
38
+ options: ExecuteAlchemyEntryOptions
39
+ ): Promise<ExecuteAlchemyEntryResult> => {
40
+ // Resolve first so a caller that does not own Alchemy fails before the
41
+ // bundle writes anything.
42
+ const cliEntry = createRequire(options.from).resolve(
43
+ "alchemy/bin/alchemy.js"
44
+ );
45
+ const bundledEntry = await bundleAlchemyEntry(options.bundle);
46
+ const result = spawnSync(
47
+ process.execPath,
48
+ [cliEntry, ...options.args, bundledEntry],
49
+ {
50
+ cwd: options.cwd ?? options.bundle.absWorkingDir ?? process.cwd(),
51
+ encoding: "utf-8",
52
+ env: options.env ?? process.env,
53
+ stdio: options.stdio ?? "inherit",
54
+ }
55
+ );
56
+
57
+ if (result.error) {
58
+ // Node's spawn error carries `spawnargs`; retaining it as `cause` would
59
+ // let ordinary recursive error logging reveal caller arguments.
60
+ throw new Error("Failed to start the Alchemy CLI.");
61
+ }
62
+ if (result.signal) {
63
+ throw new Error(`Alchemy CLI terminated by signal ${result.signal}.`);
64
+ }
65
+ if (result.status !== 0) {
66
+ throw new Error(
67
+ result.status === null
68
+ ? "Alchemy CLI exited without reporting a status."
69
+ : `Alchemy CLI exited with status ${result.status}.`
70
+ );
71
+ }
72
+
73
+ return {
74
+ bundledEntry,
75
+ stderr: result.stderr,
76
+ stdout: result.stdout,
77
+ };
78
+ };
@@ -0,0 +1,208 @@
1
+ import type { NodePnpmActionFamily, PinnedAction } from "./actions.ts";
2
+
3
+ const PINNED_ACTION_PATTERN = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/u;
4
+ const ACTION_TAG_PATTERN = /^v\d+(?:\.\d+){0,2}$/u;
5
+ const RESERVED_ACTION_NAMES = new Set(["checkout", "setupNode", "setupPnpm"]);
6
+
7
+ export interface WorkflowStep {
8
+ readonly name: string;
9
+ readonly uses?: string;
10
+ readonly with?: Readonly<Record<string, string>>;
11
+ readonly run?: string;
12
+ }
13
+
14
+ export interface CheckoutStepOptions {
15
+ readonly name?: string;
16
+ readonly ref?: string;
17
+ }
18
+
19
+ export interface SetupNodeStepOptions {
20
+ readonly cacheDependencyPath?: string;
21
+ readonly nodeVersionFile?: string;
22
+ }
23
+
24
+ export interface InstallStepOptions {
25
+ readonly name?: string;
26
+ readonly run?: string;
27
+ }
28
+
29
+ export interface FactoryWorkflowSetupOptions {
30
+ readonly checkout?: CheckoutStepOptions;
31
+ readonly install?: InstallStepOptions;
32
+ readonly setupNode?: SetupNodeStepOptions;
33
+ }
34
+
35
+ export interface FactoryWorkflowOptions<
36
+ Additional extends Readonly<Record<string, PinnedAction>>,
37
+ > {
38
+ /**
39
+ * Required complete family. There is no default, so adopting this package
40
+ * cannot silently move a consumer from one action major to another.
41
+ */
42
+ readonly actionFamily: NodePnpmActionFamily;
43
+ /** Every workflow-specific action source this artifact owns. */
44
+ readonly additionalActions?: Additional;
45
+ readonly generated: {
46
+ /** Generator path relative to the repository root. */
47
+ readonly source: string;
48
+ /** Exact command that regenerates the emitted workflow. */
49
+ readonly regenerate: string;
50
+ };
51
+ readonly setup?: FactoryWorkflowSetupOptions;
52
+ }
53
+
54
+ type FamilyActions = Omit<NodePnpmActionFamily, "id">;
55
+
56
+ export interface FactoryWorkflowArtifact<
57
+ Additional extends Readonly<Record<string, PinnedAction>>,
58
+ > {
59
+ readonly actionFamilyId: string;
60
+ readonly actions: Readonly<FamilyActions & Additional>;
61
+ /** Checkout → pnpm → Node → install, customized by named role. */
62
+ readonly setupSteps: readonly WorkflowStep[];
63
+ /**
64
+ * Structurally accepted by gagen's `writeOrLint`; this package deliberately
65
+ * has no gagen dependency.
66
+ */
67
+ readonly writeOptions: {
68
+ readonly header: string;
69
+ readonly pinDeps: false;
70
+ };
71
+ }
72
+
73
+ const assertSingleLine = (name: string, value: string): void => {
74
+ if (value.trim() !== value || value.length === 0 || /[\r\n]/u.test(value)) {
75
+ throw new Error(`${name} must be a single non-empty line.`);
76
+ }
77
+ };
78
+
79
+ const actionRepository = (action: PinnedAction): string =>
80
+ action.uses.slice(0, action.uses.indexOf("@"));
81
+
82
+ const assertPinnedAction = (
83
+ name: string,
84
+ action: PinnedAction,
85
+ expectedRepository?: string
86
+ ): void => {
87
+ if (!PINNED_ACTION_PATTERN.test(action.uses)) {
88
+ throw new Error(
89
+ `${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`
90
+ );
91
+ }
92
+ if (expectedRepository && actionRepository(action) !== expectedRepository) {
93
+ throw new Error(
94
+ `${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`
95
+ );
96
+ }
97
+ if (!ACTION_TAG_PATTERN.test(action.tag)) {
98
+ throw new Error(
99
+ `${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`
100
+ );
101
+ }
102
+ };
103
+
104
+ const usesStep = (
105
+ fallbackName: string,
106
+ action: PinnedAction
107
+ ): WorkflowStep => ({
108
+ name: fallbackName,
109
+ uses: action.uses,
110
+ });
111
+
112
+ const assertActionFamily = (family: NodePnpmActionFamily): void => {
113
+ assertSingleLine("actionFamily.id", family.id);
114
+ assertPinnedAction("checkout", family.checkout, "actions/checkout");
115
+ assertPinnedAction("setupNode", family.setupNode, "actions/setup-node");
116
+ assertPinnedAction("setupPnpm", family.setupPnpm, "pnpm/action-setup");
117
+ };
118
+
119
+ const assertAdditionalActions = (
120
+ actions: Readonly<Record<string, PinnedAction>>
121
+ ): void => {
122
+ for (const [name, action] of Object.entries(actions)) {
123
+ if (RESERVED_ACTION_NAMES.has(name)) {
124
+ throw new Error(
125
+ `${name} is reserved by the Node/pnpm action family and cannot be replaced by additionalActions.`
126
+ );
127
+ }
128
+ assertPinnedAction(name, action);
129
+ }
130
+ };
131
+
132
+ const setupSteps = (
133
+ family: NodePnpmActionFamily,
134
+ setup: FactoryWorkflowSetupOptions = {}
135
+ ): readonly WorkflowStep[] => {
136
+ const installRun = setup.install?.run ?? "pnpm install --frozen-lockfile";
137
+ if (/(?:^|\s)--ignore-scripts(?:\s|$)/u.test(installRun)) {
138
+ throw new Error(
139
+ "The install step must not use --ignore-scripts; workspace prepare scripts build required package artifacts."
140
+ );
141
+ }
142
+
143
+ const { checkout, setupNode } = setup;
144
+ return Object.freeze([
145
+ {
146
+ name: checkout?.name ?? "Checkout",
147
+ uses: family.checkout.uses,
148
+ ...(checkout?.ref ? { with: { ref: checkout.ref } } : {}),
149
+ },
150
+ usesStep("Setup pnpm", family.setupPnpm),
151
+ {
152
+ name: "Setup Node",
153
+ uses: family.setupNode.uses,
154
+ with: {
155
+ cache: "pnpm",
156
+ ...(setupNode?.cacheDependencyPath
157
+ ? { "cache-dependency-path": setupNode.cacheDependencyPath }
158
+ : {}),
159
+ "node-version-file": setupNode?.nodeVersionFile ?? ".nvmrc",
160
+ },
161
+ },
162
+ {
163
+ name: setup.install?.name ?? "Install workspace",
164
+ run: installRun,
165
+ },
166
+ ]);
167
+ };
168
+
169
+ /**
170
+ * Own one generated workflow source's action pins, setup block, provenance
171
+ * banner, and gagen pinning policy as a single artifact.
172
+ *
173
+ * The caller still owns workflow topology, jobs, runners, permissions, and
174
+ * every deployment policy decision.
175
+ */
176
+ export const factoryWorkflow = <
177
+ const Additional extends Readonly<Record<string, PinnedAction>> = Record<
178
+ never,
179
+ never
180
+ >,
181
+ >(
182
+ options: FactoryWorkflowOptions<Additional>
183
+ ): FactoryWorkflowArtifact<Additional> => {
184
+ assertSingleLine("generated.source", options.generated.source);
185
+ assertSingleLine("generated.regenerate", options.generated.regenerate);
186
+ assertActionFamily(options.actionFamily);
187
+ assertAdditionalActions(options.additionalActions ?? {});
188
+
189
+ const actions = Object.freeze({
190
+ checkout: options.actionFamily.checkout,
191
+ setupNode: options.actionFamily.setupNode,
192
+ setupPnpm: options.actionFamily.setupPnpm,
193
+ ...(options.additionalActions ?? ({} as Additional)),
194
+ }) as Readonly<FamilyActions & Additional>;
195
+
196
+ return Object.freeze({
197
+ actionFamilyId: options.actionFamily.id,
198
+ actions,
199
+ setupSteps: setupSteps(options.actionFamily, options.setup),
200
+ writeOptions: Object.freeze({
201
+ header: [
202
+ `# GENERATED BY ${options.generated.source} -- DO NOT EDIT.`,
203
+ `# Regenerate: ${options.generated.regenerate}`,
204
+ ].join("\n"),
205
+ pinDeps: false as const,
206
+ }),
207
+ });
208
+ };
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@patronage/factory-ci` — the CI and deploy building blocks that already
3
+ * repeat across Patronage factory projects (#266, #268).
4
+ *
5
+ * A pure library: no `bin`, no `alchemy` or `effect` dependency, no config
6
+ * surface of its own. Everything takes a plain typed options object; mapping
7
+ * `software-factory.profile.json` into those objects is the profile's job, not
8
+ * this package's (ADR 0021).
9
+ */
10
+
11
+ export {
12
+ NODE_PNPM_ACTION_FAMILY_V4,
13
+ type NodePnpmActionFamily,
14
+ type PinnedAction,
15
+ } from "./actions.ts";
16
+ export {
17
+ bundleAlchemyEntry,
18
+ type BundleAlchemyEntryOptions,
19
+ } from "./bundle-alchemy-entry.ts";
20
+ export {
21
+ isLocalPreviewStage,
22
+ localPreviewStage,
23
+ type LocalPreviewStage,
24
+ type LocalPreviewStageOptions,
25
+ type ParsedLocalPreviewStage,
26
+ type ParseLocalPreviewStageExpected,
27
+ parseLocalPreviewStage,
28
+ } from "./disposable-stage.ts";
29
+ export {
30
+ type CheckoutStepOptions,
31
+ type FactoryWorkflowArtifact,
32
+ type FactoryWorkflowOptions,
33
+ type FactoryWorkflowSetupOptions,
34
+ factoryWorkflow,
35
+ type InstallStepOptions,
36
+ type SetupNodeStepOptions,
37
+ type WorkflowStep,
38
+ } from "./factory-workflow.ts";
39
+ export {
40
+ type ExecuteAlchemyEntryOptions,
41
+ type ExecuteAlchemyEntryResult,
42
+ executeAlchemyEntry,
43
+ } from "./execute-alchemy-entry.ts";