@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.
@@ -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";
@@ -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. An empty required set would make *every* passing proof
197
- * trivially covering, so it is never silently treated as "requires nothing";
198
- * callers must refuse instead.
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
- return [...new Set(commands.map(({ name }) => name))].toSorted();
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
- * Skipped work covered under a different command string, keyed by the
583
- * skipped string with the reason as its value. Deliberately explicit: a
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