@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "0.2.1",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -22,22 +22,20 @@
22
22
  "type": "module",
23
23
  "exports": {
24
24
  ".": {
25
- "development": {
26
- "types": "./src/index.ts",
27
- "default": "./src/index.ts"
28
- },
29
25
  "types": "./dist/index.d.ts",
30
26
  "default": "./dist/index.js"
31
27
  }
32
28
  },
33
29
  "publishConfig": {
34
- "access": "public"
30
+ "access": "public",
31
+ "tag": "next"
35
32
  },
36
33
  "dependencies": {
37
34
  "esbuild": "0.28.1"
38
35
  },
39
36
  "devDependencies": {
40
37
  "@types/node": "24.13.3",
38
+ "@vitest/coverage-v8": "4.1.10",
41
39
  "oxfmt": "0.59.0",
42
40
  "oxlint": "1.74.0",
43
41
  "tsdown": "0.21.10",
@@ -56,6 +54,8 @@
56
54
  "prefix": "bash ../scripts/ensure-worktree-bootstrap.sh",
57
55
  "fix": "ultracite fix",
58
56
  "pretest": "bash ../scripts/ensure-worktree-bootstrap.sh",
57
+ "precoverage": "bash ../scripts/ensure-worktree-bootstrap.sh",
58
+ "coverage": "vitest run --coverage",
59
59
  "test": "vitest run",
60
60
  "pretypecheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
61
61
  "typecheck": "tsc --noEmit"
@@ -10,6 +10,79 @@ import { build } from "esbuild";
10
10
  */
11
11
  const ALCHEMY_EXTERNALS = ["alchemy", "alchemy/*", "effect", "effect/*"];
12
12
 
13
+ /**
14
+ * The package names behind `ALCHEMY_EXTERNALS`, derived rather than restated so
15
+ * a future external can never be guarded by only one of two lists.
16
+ */
17
+ const RESERVED_ALIAS_PACKAGES = [
18
+ ...new Set(
19
+ ALCHEMY_EXTERNALS.map((external) =>
20
+ external.replace(/\/\*$/u, "").toLowerCase()
21
+ )
22
+ ),
23
+ ];
24
+
25
+ const reservedAliasKey = (key: string): string | undefined =>
26
+ RESERVED_ALIAS_PACKAGES.find(
27
+ (name) => key === name || key.startsWith(`${name}/`)
28
+ );
29
+
30
+ /**
31
+ * Refuse an alias *key* that is itself a reserved package or one of its
32
+ * subpaths. esbuild substitutes aliases before it decides what is external, so
33
+ * such a key defeats `external` outright. Keys are compared, never resolved,
34
+ * which is what makes string matching sound here.
35
+ */
36
+ const assertAliasKeysAreAdmissible = (
37
+ alias: Readonly<Record<string, string>>
38
+ ): void => {
39
+ for (const key of Object.keys(alias)) {
40
+ const reserved = reservedAliasKey(key);
41
+ if (reserved) {
42
+ throw new Error(
43
+ `bundleAlchemyEntry cannot alias "${key}": ${reserved} must stay external because its identity is shared with the consumer's runtime, and esbuild applies aliases before external matching. Point the consumer's own resolution at one copy instead.`
44
+ );
45
+ }
46
+ }
47
+ };
48
+
49
+ /**
50
+ * Whether a bundled input lives inside a reserved package.
51
+ *
52
+ * Metafile inputs are paths esbuild resolved and normalized itself, so
53
+ * comparing whole segments here answers what was *bundled* rather than how the
54
+ * config was spelled. Segments are compared case-insensitively: on a
55
+ * case-insensitive filesystem `node_modules/Effect/…` resolves to the real
56
+ * package and the metafile keeps the caller's spelling.
57
+ */
58
+ const isReservedInput = (input: string): boolean => {
59
+ const segments = input.toLowerCase().split(/[/\\]+/u);
60
+ return segments.some(
61
+ (segment, position) =>
62
+ position > 0 &&
63
+ segments[position - 1] === "node_modules" &&
64
+ RESERVED_ALIAS_PACKAGES.includes(segment)
65
+ );
66
+ };
67
+
68
+ /**
69
+ * Assert the externals contract against the bundle esbuild actually produced.
70
+ *
71
+ * Checking alias *values* instead was unsound by construction: `..` segments,
72
+ * symlinks, and every other spelling of the same file each need another string
73
+ * rule, and the scanner loses. The metafile records the inputs after esbuild's
74
+ * own resolution and normalization, so one check closes the whole class —
75
+ * whatever route reached an identity-sensitive package, it shows up here.
76
+ */
77
+ const assertNoReservedInputs = (inputs: readonly string[]): void => {
78
+ const offenders = inputs.filter(isReservedInput);
79
+ if (offenders.length > 0) {
80
+ throw new Error(
81
+ `bundleAlchemyEntry refused a bundle carrying a second copy of an identity-sensitive package: ${offenders.join(", ")}. Those packages must resolve from the consumer's runtime, so nothing may pull their files into the bundle — an alias that re-exports them by bare specifier stays external and is fine.`
82
+ );
83
+ }
84
+ };
85
+
13
86
  export interface BundleAlchemyEntryOptions {
14
87
  /** The Alchemy entry to bundle, e.g. `alchemy.run.ts`. */
15
88
  readonly entry: string;
@@ -17,6 +90,18 @@ export interface BundleAlchemyEntryOptions {
17
90
  readonly outfile: string;
18
91
  /** esbuild's working directory; also the base for relative paths. */
19
92
  readonly absWorkingDir?: string;
93
+ /**
94
+ * Import specifiers to rewrite before resolution, passed straight to
95
+ * esbuild's `alias`. Substitution happens before the `packages` and
96
+ * `external` decisions, so an aliased bare specifier is inlined even under
97
+ * `packages: "external"`. Values are resolved the way esbuild resolves any
98
+ * import, so give absolute paths or package names — which aliases a repo
99
+ * needs is the consumer's policy and this package bakes in none. A key on
100
+ * `alchemy`, `effect`, or a subpath of either is rejected outright, and the
101
+ * finished bundle is checked for files from those packages however they were
102
+ * reached.
103
+ */
104
+ readonly alias?: Readonly<Record<string, string>>;
20
105
  /**
21
106
  * `"external"` (default) leaves every bare import outside the entry's own
22
107
  * source graph to Node's resolver at run time — the entry's TypeScript is
@@ -49,6 +134,10 @@ export interface BundleAlchemyEntryOptions {
49
134
  export const bundleAlchemyEntry = async (
50
135
  options: BundleAlchemyEntryOptions
51
136
  ): Promise<string> => {
137
+ if (options.alias) {
138
+ assertAliasKeysAreAdmissible(options.alias);
139
+ }
140
+
52
141
  const root = options.absWorkingDir
53
142
  ? path.resolve(options.absWorkingDir)
54
143
  : process.cwd();
@@ -56,21 +145,25 @@ export const bundleAlchemyEntry = async (
56
145
 
57
146
  await mkdir(path.dirname(outfile), { recursive: true });
58
147
 
59
- await build({
148
+ const result = await build({
60
149
  absWorkingDir: root,
61
150
  bundle: true,
62
151
  entryPoints: [path.resolve(root, options.entry)],
63
152
  external: ALCHEMY_EXTERNALS,
64
153
  format: "esm",
154
+ metafile: true,
65
155
  outfile,
66
156
  packages: options.packages ?? "external",
67
157
  platform: "node",
68
158
  sourcemap: options.sourcemap ?? false,
69
159
  target: options.target ?? "node24",
160
+ ...(options.alias ? { alias: { ...options.alias } } : {}),
70
161
  ...(options.tsconfig
71
162
  ? { tsconfig: path.resolve(root, options.tsconfig) }
72
163
  : {}),
73
164
  });
74
165
 
166
+ assertNoReservedInputs(Object.keys(result.metafile.inputs));
167
+
75
168
  return outfile;
76
169
  };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pull-request events that can create or refresh a Factory candidate run.
3
+ *
4
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
5
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
6
+ * jobs idle until the pull request is a candidate.
7
+ */
8
+ export const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
9
+ "opened",
10
+ "synchronize",
11
+ "reopened",
12
+ "ready_for_review",
13
+ ] as const);
14
+
15
+ /**
16
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
17
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
18
+ */
19
+ export const factoryCandidateOrPushCondition = (
20
+ candidateCondition?: string
21
+ ): string => {
22
+ const candidate = [
23
+ "github.event_name == 'pull_request'",
24
+ "github.event.pull_request.draft != true",
25
+ ...(candidateCondition ? [candidateCondition] : []),
26
+ ].join(" && ");
27
+
28
+ return ["github.event_name == 'push'", `(${candidate})`].join(" || ");
29
+ };
@@ -1,10 +1,13 @@
1
1
  import type { NodePnpmActionFamily, PinnedAction } from "./actions.ts";
2
+ import { assertPinnedAction } from "./pinned-action.ts";
2
3
 
3
- const PINNED_ACTION_PATTERN = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/u;
4
- const ACTION_TAG_PATTERN = /^v\d+(?:\.\d+){0,2}$/u;
5
4
  const RESERVED_ACTION_NAMES = new Set(["checkout", "setupNode", "setupPnpm"]);
6
5
 
7
6
  export interface WorkflowStep {
7
+ readonly continueOnError?: boolean;
8
+ readonly env?: Readonly<Record<string, string>>;
9
+ readonly id?: string;
10
+ readonly if?: string;
8
11
  readonly name: string;
9
12
  readonly uses?: string;
10
13
  readonly with?: Readonly<Record<string, string>>;
@@ -12,6 +15,7 @@ export interface WorkflowStep {
12
15
  }
13
16
 
14
17
  export interface CheckoutStepOptions {
18
+ readonly fetchDepth?: number;
15
19
  readonly name?: string;
16
20
  readonly ref?: string;
17
21
  }
@@ -76,31 +80,6 @@ const assertSingleLine = (name: string, value: string): void => {
76
80
  }
77
81
  };
78
82
 
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
83
  const usesStep = (
105
84
  fallbackName: string,
106
85
  action: PinnedAction
@@ -129,6 +108,25 @@ const assertAdditionalActions = (
129
108
  }
130
109
  };
131
110
 
111
+ const checkoutInputs = (
112
+ checkout: CheckoutStepOptions | undefined
113
+ ): Readonly<Record<string, string>> | undefined => {
114
+ if (checkout === undefined) {
115
+ return undefined;
116
+ }
117
+ const inputs: Record<string, string> = {};
118
+ if (checkout.fetchDepth !== undefined) {
119
+ if (!Number.isSafeInteger(checkout.fetchDepth) || checkout.fetchDepth < 0) {
120
+ throw new Error("checkout.fetchDepth must be a non-negative integer.");
121
+ }
122
+ inputs["fetch-depth"] = String(checkout.fetchDepth);
123
+ }
124
+ if (checkout.ref) {
125
+ inputs.ref = checkout.ref;
126
+ }
127
+ return Object.keys(inputs).length > 0 ? inputs : undefined;
128
+ };
129
+
132
130
  const setupSteps = (
133
131
  family: NodePnpmActionFamily,
134
132
  setup: FactoryWorkflowSetupOptions = {}
@@ -141,11 +139,12 @@ const setupSteps = (
141
139
  }
142
140
 
143
141
  const { checkout, setupNode } = setup;
142
+ const checkoutWith = checkoutInputs(checkout);
144
143
  return Object.freeze([
145
144
  {
146
145
  name: checkout?.name ?? "Checkout",
147
146
  uses: family.checkout.uses,
148
- ...(checkout?.ref ? { with: { ref: checkout.ref } } : {}),
147
+ ...(checkoutWith ? { with: checkoutWith } : {}),
149
148
  },
150
149
  usesStep("Setup pnpm", family.setupPnpm),
151
150
  {
@@ -0,0 +1,162 @@
1
+ import { createSign } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ /**
5
+ * Minting a GitHub App installation token: the RS256 app JWT, the optional
6
+ * installation lookup, and the token exchange (#617).
7
+ *
8
+ * Two projects had grown the same three steps independently — the factory's
9
+ * check-run publisher and paitronage's proof-comment publisher — which is the
10
+ * admitted-on-repetition bar. Only the *mechanism* lives here. Where the
11
+ * private key comes from, how the app id is configured, and what the token is
12
+ * then used for stay with each consumer: this module is handed credentials and
13
+ * returns a token.
14
+ */
15
+
16
+ /** The default request budget, matching the factory's other GitHub writes. */
17
+ const DEFAULT_TIMEOUT_MS = 5000;
18
+
19
+ /** Nine-minute JWT lifetime, backdated a minute against runner clock skew. */
20
+ const JWT_BACKDATE_SECONDS = 60;
21
+ const JWT_LIFETIME_SECONDS = 600;
22
+
23
+ /**
24
+ * What a consumer must know to mint: the app id, where the private key is, and
25
+ * — when it has been recorded — which installation to mint against.
26
+ *
27
+ * `installationId` is optional because the installation is discoverable from
28
+ * the repository. `privateKeyPath` is a path rather than key material so no
29
+ * consumer has to hold a secret in memory to call this, and so the key-path
30
+ * convention stays the consumer's.
31
+ */
32
+ export interface GithubAppCredentials {
33
+ appId: number | string;
34
+ installationId?: number;
35
+ privateKeyPath: string;
36
+ }
37
+
38
+ /** Carries the HTTP status so a caller can tell a retryable failure apart. */
39
+ export class GitHubApiError extends Error {
40
+ readonly status: number;
41
+
42
+ constructor(status: number, statusText: string) {
43
+ super(`GitHub API ${status} ${statusText}`);
44
+ this.name = "GitHubApiError";
45
+ this.status = status;
46
+ }
47
+ }
48
+
49
+ export interface GithubAppTokenOptions {
50
+ /** Injectable `fetch` (tests, or a caller with its own instrumented one). */
51
+ fetch?: typeof fetch;
52
+ /** Wall clock in milliseconds; only the JWT's validity window uses it. */
53
+ now?: () => number;
54
+ /** Injectable key read, so a caller can hold the PEM itself if it must. */
55
+ readPrivateKey?: (privateKeyPath: string) => Buffer | string;
56
+ /** Per-request timeout; defaults to five seconds. */
57
+ timeoutMs?: number;
58
+ }
59
+
60
+ const base64url = (value: Buffer | string): string =>
61
+ Buffer.from(value).toString("base64url");
62
+
63
+ /**
64
+ * The signed app JWT GitHub accepts as `Authorization: Bearer` for the App
65
+ * endpoints. `iss` is stringified because GitHub accepts either spelling and a
66
+ * numeric app id must not depend on JSON's number formatting.
67
+ *
68
+ * The signature is produced from the key on disk and returned; the key
69
+ * material itself never leaves this call.
70
+ */
71
+ export const githubAppJwt = (
72
+ credentials: GithubAppCredentials,
73
+ options: Pick<GithubAppTokenOptions, "now" | "readPrivateKey"> = {}
74
+ ): string => {
75
+ const nowMs = (options.now ?? Date.now)();
76
+ const issuedAt = Math.floor(nowMs / 1000) - JWT_BACKDATE_SECONDS;
77
+ const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
78
+ const payload = base64url(
79
+ JSON.stringify({
80
+ exp: issuedAt + JWT_LIFETIME_SECONDS,
81
+ iat: issuedAt,
82
+ iss: String(credentials.appId),
83
+ })
84
+ );
85
+ const unsigned = `${header}.${payload}`;
86
+ const readKey = options.readPrivateKey ?? readFileSync;
87
+ const signer = createSign("RSA-SHA256");
88
+ signer.update(unsigned);
89
+ signer.end();
90
+ return `${unsigned}.${signer.sign(readKey(credentials.privateKeyPath), "base64url")}`;
91
+ };
92
+
93
+ const githubAppJson = async (
94
+ request: typeof fetch,
95
+ url: string,
96
+ jwt: string,
97
+ method: "GET" | "POST",
98
+ timeoutMs: number
99
+ ): Promise<Record<string, unknown>> => {
100
+ const response = await request(url, {
101
+ headers: {
102
+ Accept: "application/vnd.github+json",
103
+ Authorization: `Bearer ${jwt}`,
104
+ "X-GitHub-Api-Version": "2022-11-28",
105
+ },
106
+ method,
107
+ signal: AbortSignal.timeout(timeoutMs),
108
+ });
109
+ if (!response.ok) {
110
+ throw new GitHubApiError(response.status, response.statusText);
111
+ }
112
+ return (await response.json()) as Record<string, unknown>;
113
+ };
114
+
115
+ /**
116
+ * Mint an installation access token for one repository.
117
+ *
118
+ * When the credentials omit `installationId`, the installation is discovered
119
+ * from the repository first — the same call every consumer had written for
120
+ * itself. Nothing is cached: the token is returned to the caller and this
121
+ * module keeps no copy.
122
+ */
123
+ export const mintInstallationToken = async (
124
+ input: {
125
+ credentials: GithubAppCredentials;
126
+ owner: string;
127
+ repo: string;
128
+ },
129
+ options: GithubAppTokenOptions = {}
130
+ ): Promise<string> => {
131
+ const { credentials } = input;
132
+ const request = options.fetch ?? fetch;
133
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
134
+ const jwt = githubAppJwt(credentials, options);
135
+
136
+ let { installationId } = credentials;
137
+ if (installationId === undefined) {
138
+ const installation = await githubAppJson(
139
+ request,
140
+ `https://api.github.com/repos/${input.owner}/${input.repo}/installation`,
141
+ jwt,
142
+ "GET",
143
+ timeoutMs
144
+ );
145
+ if (typeof installation.id !== "number") {
146
+ throw new TypeError("GitHub App installation response omitted id");
147
+ }
148
+ installationId = installation.id;
149
+ }
150
+
151
+ const minted = await githubAppJson(
152
+ request,
153
+ `https://api.github.com/app/installations/${installationId}/access_tokens`,
154
+ jwt,
155
+ "POST",
156
+ timeoutMs
157
+ );
158
+ if (typeof minted.token !== "string") {
159
+ throw new TypeError("GitHub App token response omitted token");
160
+ }
161
+ return minted.token;
162
+ };
package/src/index.ts CHANGED
@@ -13,6 +13,10 @@ export {
13
13
  type NodePnpmActionFamily,
14
14
  type PinnedAction,
15
15
  } from "./actions.ts";
16
+ export {
17
+ FACTORY_CANDIDATE_PULL_REQUEST_TYPES,
18
+ factoryCandidateOrPushCondition,
19
+ } from "./candidate-lifecycle.ts";
16
20
  export {
17
21
  bundleAlchemyEntry,
18
22
  type BundleAlchemyEntryOptions,
@@ -36,6 +40,13 @@ export {
36
40
  type SetupNodeStepOptions,
37
41
  type WorkflowStep,
38
42
  } from "./factory-workflow.ts";
43
+ export {
44
+ GitHubApiError,
45
+ type GithubAppCredentials,
46
+ githubAppJwt,
47
+ type GithubAppTokenOptions,
48
+ mintInstallationToken,
49
+ } from "./github-app-token.ts";
39
50
  export {
40
51
  type ExecuteAlchemyEntryOptions,
41
52
  type ExecuteAlchemyEntryResult,
@@ -52,7 +63,9 @@ export {
52
63
  FACTORY_PROOF_GATE_REASON_OUTPUT,
53
64
  FACTORY_PROOF_GATE_REASONS,
54
65
  FACTORY_PROOF_GATE_SHELL,
66
+ FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT,
55
67
  FACTORY_PROOF_GATE_STEP_ID,
68
+ FACTORY_PROOF_GATE_STEP_NAME,
56
69
  type FactoryProofGateOptions,
57
70
  type FactoryProofGateReason,
58
71
  factoryProofGateScript,
@@ -63,4 +76,71 @@ export {
63
76
  type ProofReuseCoverageInput,
64
77
  type ProofReuseCoverageReport,
65
78
  proofReuseRequiredCommands,
79
+ resolveProofReuseCommands,
66
80
  } from "./proof-reuse-gate.ts";
81
+ export {
82
+ type FactoryProofReusePresentationOptions,
83
+ type FactoryProofReuseSummaryStep,
84
+ type FactoryProofTimingStartStep,
85
+ FACTORY_PROOF_TIMING_START_STEP_NAME,
86
+ FACTORY_PROOF_TIMING_STEP_ID,
87
+ FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
88
+ factoryProofReuseSummaryScript,
89
+ factoryProofReuseSummaryStep,
90
+ factoryProofTimingStartStep,
91
+ } from "./proof-reuse-presentation.ts";
92
+ export {
93
+ FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT,
94
+ FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT,
95
+ FACTORY_PRODUCTION_IMPACT_STEP_ID,
96
+ FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT,
97
+ type FactoryProductionImpactWorkflow,
98
+ type FactoryProductionImpactWorkflowOptions,
99
+ factoryProductionImpactWorkflow,
100
+ productionImpactTargetOutput,
101
+ } from "./production-impact-workflow.ts";
102
+ export {
103
+ FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX,
104
+ FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID,
105
+ FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID,
106
+ FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID,
107
+ FACTORY_PUSH_IDENTITY_RECORD_STEP_ID,
108
+ FACTORY_PUSH_IDENTITY_SCHEMA_VERSION,
109
+ FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID,
110
+ type FactoryPushIdentityConsumer,
111
+ type FactoryPushIdentityConsumerOptions,
112
+ type FactoryPushIdentityDisposition,
113
+ type FactoryPushIdentityEnvelope,
114
+ factoryPushIdentityConsumer,
115
+ type FactoryPushIdentityProducer,
116
+ type FactoryPushIdentityProducerOptions,
117
+ factoryPushIdentityProducer,
118
+ } from "./push-identity-workflow.ts";
119
+ export {
120
+ captureVitestProfileEnvironment,
121
+ normalizeVitestProfileSample,
122
+ runVitestProfile,
123
+ VITEST_PROFILE_SCHEMA_VERSION,
124
+ VITEST_PROFILE_TOOL,
125
+ type VitestJsonReport,
126
+ type VitestProfile,
127
+ type VitestProfileDependencies,
128
+ type VitestProfileDurationSummary,
129
+ type VitestProfileEnvironment,
130
+ VitestProfileError,
131
+ type VitestProfileOptions,
132
+ type VitestProfileSample,
133
+ type VitestProfileSampleExecution,
134
+ type VitestTestStatus,
135
+ writeVitestProfile,
136
+ } from "./vitest-profile.ts";
137
+ export {
138
+ readVitestProfileDocument,
139
+ type VitestProfileReadResult,
140
+ } from "./vitest-profile-reader.ts";
141
+ export {
142
+ assertWorkflowShellParses,
143
+ workflowRunBlocks,
144
+ type WorkflowShellParseFailure,
145
+ workflowShellParseFailures,
146
+ } from "./workflow-shell-lint.ts";
@@ -0,0 +1,30 @@
1
+ import type { 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
+
6
+ const actionRepository = (action: PinnedAction): string =>
7
+ action.uses.slice(0, action.uses.indexOf("@"));
8
+
9
+ /** Shared validator for every action pin emitted by factory-ci helpers. */
10
+ export const assertPinnedAction = (
11
+ name: string,
12
+ action: PinnedAction,
13
+ expectedRepository?: string
14
+ ): void => {
15
+ if (!PINNED_ACTION_PATTERN.test(action.uses)) {
16
+ throw new Error(
17
+ `${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`
18
+ );
19
+ }
20
+ if (expectedRepository && actionRepository(action) !== expectedRepository) {
21
+ throw new Error(
22
+ `${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`
23
+ );
24
+ }
25
+ if (!ACTION_TAG_PATTERN.test(action.tag)) {
26
+ throw new Error(
27
+ `${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`
28
+ );
29
+ }
30
+ };