@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
|
@@ -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
|
@@ -36,6 +36,13 @@ export {
|
|
|
36
36
|
type SetupNodeStepOptions,
|
|
37
37
|
type WorkflowStep,
|
|
38
38
|
} from "./factory-workflow.ts";
|
|
39
|
+
export {
|
|
40
|
+
GitHubApiError,
|
|
41
|
+
type GithubAppCredentials,
|
|
42
|
+
githubAppJwt,
|
|
43
|
+
type GithubAppTokenOptions,
|
|
44
|
+
mintInstallationToken,
|
|
45
|
+
} from "./github-app-token.ts";
|
|
39
46
|
export {
|
|
40
47
|
type ExecuteAlchemyEntryOptions,
|
|
41
48
|
type ExecuteAlchemyEntryResult,
|
|
@@ -64,3 +71,9 @@ export {
|
|
|
64
71
|
type ProofReuseCoverageReport,
|
|
65
72
|
proofReuseRequiredCommands,
|
|
66
73
|
} from "./proof-reuse-gate.ts";
|
|
74
|
+
export {
|
|
75
|
+
assertWorkflowShellParses,
|
|
76
|
+
workflowRunBlocks,
|
|
77
|
+
type WorkflowShellParseFailure,
|
|
78
|
+
workflowShellParseFailures,
|
|
79
|
+
} from "./workflow-shell-lint.ts";
|
package/src/proof-reuse-gate.ts
CHANGED
|
@@ -193,9 +193,11 @@ const isProofReuseCommand = (value: unknown): value is ProofReuseCommand => {
|
|
|
193
193
|
*
|
|
194
194
|
* `undefined` means the selection is unusable — not an array, empty, or
|
|
195
195
|
* carrying an entry whose `command` is blank or whose `name` is not a plain
|
|
196
|
-
* command identity
|
|
197
|
-
*
|
|
198
|
-
*
|
|
196
|
+
* command identity, or carrying duplicate names. A name is the executable
|
|
197
|
+
* authorization identity recorded in proof, so two commands may never collapse
|
|
198
|
+
* behind one. An empty required set would make *every* passing proof trivially
|
|
199
|
+
* covering, so it is never silently treated as "requires nothing"; callers
|
|
200
|
+
* must refuse instead.
|
|
199
201
|
*/
|
|
200
202
|
export const proofReuseRequiredCommands = (
|
|
201
203
|
commands: readonly ProofReuseCommand[]
|
|
@@ -206,7 +208,11 @@ export const proofReuseRequiredCommands = (
|
|
|
206
208
|
if (!commands.every(isProofReuseCommand)) {
|
|
207
209
|
return;
|
|
208
210
|
}
|
|
209
|
-
|
|
211
|
+
const names = commands.map(({ name }) => name);
|
|
212
|
+
if (new Set(names).size !== names.length) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
return names.toSorted();
|
|
210
216
|
};
|
|
211
217
|
|
|
212
218
|
/**
|
|
@@ -579,10 +585,8 @@ export interface ProofReuseCoverageInput {
|
|
|
579
585
|
/** The same selection handed to the gate for this surface. */
|
|
580
586
|
readonly commands: readonly ProofReuseCommand[];
|
|
581
587
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
* prose rationale for why some narrower command is "equivalent enough" is
|
|
585
|
-
* exactly what this assertion exists to force into the open.
|
|
588
|
+
* @deprecated Ignored. Consumer prose cannot authorize executable coverage;
|
|
589
|
+
* retained only so the 0.2.1 security patch remains source-compatible.
|
|
586
590
|
*/
|
|
587
591
|
readonly equivalents?: Readonly<Record<string, string>>;
|
|
588
592
|
/**
|
|
@@ -617,7 +621,6 @@ export interface ProofReuseCoverageReport {
|
|
|
617
621
|
*/
|
|
618
622
|
export const proofReuseCoverage = ({
|
|
619
623
|
commands,
|
|
620
|
-
equivalents = {},
|
|
621
624
|
skipped,
|
|
622
625
|
}: ProofReuseCoverageInput): ProofReuseCoverageReport => {
|
|
623
626
|
const requiredCommands = proofReuseRequiredCommands(commands) ?? [];
|
|
@@ -630,15 +633,7 @@ export const proofReuseCoverage = ({
|
|
|
630
633
|
...new Set(
|
|
631
634
|
(Array.isArray(skipped) ? skipped : [])
|
|
632
635
|
.map((command) => String(command).trim())
|
|
633
|
-
.filter(
|
|
634
|
-
(command) =>
|
|
635
|
-
command.length > 0 &&
|
|
636
|
-
// `Object.hasOwn`, never `in`: `in` walks the prototype chain, so
|
|
637
|
-
// a skipped command named `toString` or `constructor` would report
|
|
638
|
-
// itself as a declared equivalent of nothing. This assertion's only
|
|
639
|
-
// job is to fail loudly.
|
|
640
|
-
!(proven.has(command) || Object.hasOwn(equivalents, command))
|
|
641
|
-
)
|
|
636
|
+
.filter((command) => command.length > 0 && !proven.has(command))
|
|
642
637
|
),
|
|
643
638
|
].toSorted();
|
|
644
639
|
|