@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 +21 -0
- package/README.md +106 -0
- package/dist/index.d.ts +228 -0
- package/dist/index.js +244 -0
- package/package.json +63 -0
- package/src/actions.ts +54 -0
- package/src/bundle-alchemy-entry.ts +76 -0
- package/src/disposable-stage.ts +115 -0
- package/src/execute-alchemy-entry.ts +78 -0
- package/src/factory-workflow.ts +208 -0
- package/src/index.ts +43 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|