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