merge-steward 0.27.1 → 0.28.0
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/dist/exec.js +38 -0
- package/dist/github-auth.d.ts +23 -0
- package/dist/github-auth.js +177 -44
- package/package.json +1 -1
package/dist/exec.js
CHANGED
|
@@ -58,6 +58,25 @@ function shouldRetryWithoutRuntimeGitHubAuth(command, githubEnv, failure) {
|
|
|
58
58
|
}
|
|
59
59
|
return (failure.stderr ?? "").includes(WORKFLOW_PERMISSION_REJECTION);
|
|
60
60
|
}
|
|
61
|
+
// Markers that indicate GitHub rejected our credentials (stale/expired installation token).
|
|
62
|
+
const AUTH_FAILURE_MARKERS = [
|
|
63
|
+
"Bad credentials",
|
|
64
|
+
"Invalid username or token",
|
|
65
|
+
"Authentication failed",
|
|
66
|
+
"could not read Username",
|
|
67
|
+
"401 Unauthorized",
|
|
68
|
+
"HTTP 401",
|
|
69
|
+
];
|
|
70
|
+
function isGitHubAuthFailure(command, githubEnv, failure) {
|
|
71
|
+
if (command !== "git" && command !== "gh")
|
|
72
|
+
return false;
|
|
73
|
+
if (Object.keys(githubEnv).length === 0)
|
|
74
|
+
return false;
|
|
75
|
+
if (failure.timedOut)
|
|
76
|
+
return false;
|
|
77
|
+
const text = `${failure.stderr ?? ""}\n${failure.stdout ?? ""}\n${failure.message ?? ""}`;
|
|
78
|
+
return AUTH_FAILURE_MARKERS.some((marker) => text.includes(marker));
|
|
79
|
+
}
|
|
61
80
|
async function runExecFile(command, args, env, options) {
|
|
62
81
|
return await new Promise((resolve, reject) => {
|
|
63
82
|
execFile(command, args, {
|
|
@@ -118,6 +137,25 @@ export async function exec(command, args, options) {
|
|
|
118
137
|
}
|
|
119
138
|
catch (error) {
|
|
120
139
|
const failure = error;
|
|
140
|
+
// Self-heal on broken auth: record the failure, proactively re-mint the installation
|
|
141
|
+
// token, and retry once with a fresh credential before giving up.
|
|
142
|
+
const repoFullName = options?.githubRepoFullName;
|
|
143
|
+
if (repoFullName
|
|
144
|
+
&& isGitHubAuthFailure(command, githubEnv, failure)
|
|
145
|
+
&& runtimeGitHubAuthProvider?.refreshTokenForRepo) {
|
|
146
|
+
runtimeGitHubAuthProvider.recordAuthFailure?.(repoFullName, (failure.stderr ?? failure.message ?? "").slice(0, 200));
|
|
147
|
+
try {
|
|
148
|
+
await runtimeGitHubAuthProvider.refreshTokenForRepo(repoFullName, "github_401");
|
|
149
|
+
const refreshedGithubEnv = resolveGitHubCommandEnv(command, baseEnv, {
|
|
150
|
+
githubRepoFullName: repoFullName,
|
|
151
|
+
runtimeAuthProvider: runtimeGitHubAuthProvider,
|
|
152
|
+
});
|
|
153
|
+
return await runExecFile(command, args, { ...baseEnv, ...refreshedGithubEnv }, options);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Fall through to the original error / workflow-permission fallback below.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
121
159
|
if (!shouldRetryWithoutRuntimeGitHubAuth(command, githubEnv, failure)) {
|
|
122
160
|
throw error;
|
|
123
161
|
}
|
package/dist/github-auth.d.ts
CHANGED
|
@@ -14,12 +14,35 @@ export type GitHubAuthConfig = {
|
|
|
14
14
|
} | {
|
|
15
15
|
mode: "none";
|
|
16
16
|
};
|
|
17
|
+
export type GitHubAuthRefreshReason = "startup" | "scheduled" | "before_use" | "github_401" | "manual";
|
|
18
|
+
export interface GitHubInstallationAuthStatus {
|
|
19
|
+
installationId: string;
|
|
20
|
+
repoFullNames: string[];
|
|
21
|
+
hasToken: boolean;
|
|
22
|
+
expiresAt: string | null;
|
|
23
|
+
lastRefreshAt: string | null;
|
|
24
|
+
lastRefreshError: string | null;
|
|
25
|
+
fresh: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface GitHubAuthRuntimeStatus {
|
|
28
|
+
ready: boolean;
|
|
29
|
+
lastRefreshAt: string | null;
|
|
30
|
+
lastRefreshError: string | null;
|
|
31
|
+
recentAuthFailureCount: number;
|
|
32
|
+
lastAuthFailureAt: string | null;
|
|
33
|
+
installations: GitHubInstallationAuthStatus[];
|
|
34
|
+
}
|
|
17
35
|
export interface RuntimeGitHubAuthProvider {
|
|
18
36
|
currentTokenForRepo(repoFullName?: string): string | undefined;
|
|
37
|
+
refreshTokenForRepo?(repoFullName: string, reason: GitHubAuthRefreshReason): Promise<void>;
|
|
38
|
+
recordAuthFailure?(repoFullName: string, message: string): void;
|
|
19
39
|
}
|
|
20
40
|
export interface GitHubAppTokenManager extends RuntimeGitHubAuthProvider {
|
|
21
41
|
start(): Promise<void>;
|
|
22
42
|
stop(): void;
|
|
43
|
+
refreshTokenForRepo(repoFullName: string, reason: GitHubAuthRefreshReason): Promise<void>;
|
|
44
|
+
recordAuthFailure(repoFullName: string, message: string): void;
|
|
45
|
+
authStatus(): GitHubAuthRuntimeStatus;
|
|
23
46
|
}
|
|
24
47
|
export declare function generateJwt(appId: string, privateKey: string): string;
|
|
25
48
|
export declare function resolveAppSlug(credentials: GitHubAppCredentials): Promise<string>;
|
package/dist/github-auth.js
CHANGED
|
@@ -2,6 +2,9 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import { createSign } from "node:crypto";
|
|
3
3
|
import { resolveSecretWithSource } from "./resolve-secret.js";
|
|
4
4
|
const TOKEN_REFRESH_MS = 30 * 60_000;
|
|
5
|
+
const TOKEN_EXPIRY_MARGIN_MS = 5 * 60_000;
|
|
6
|
+
const RECENT_AUTH_FAILURE_WINDOW_MS = 15 * 60_000;
|
|
7
|
+
const MAX_AUTH_FAILURES_TO_KEEP = 50;
|
|
5
8
|
export function generateJwt(appId, privateKey) {
|
|
6
9
|
const now = Math.floor(Date.now() / 1000);
|
|
7
10
|
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
@@ -36,10 +39,7 @@ export async function resolveAppSlug(credentials) {
|
|
|
36
39
|
return data.slug;
|
|
37
40
|
}
|
|
38
41
|
async function resolveInstallationIdForRepo(jwt, repoFullName) {
|
|
39
|
-
const encodedRepo = repoFullName
|
|
40
|
-
.split("/")
|
|
41
|
-
.map((segment) => encodeURIComponent(segment))
|
|
42
|
-
.join("/");
|
|
42
|
+
const encodedRepo = repoFullName.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
43
43
|
const data = await fetchJson(`https://api.github.com/repos/${encodedRepo}/installation`, { headers: githubHeaders(jwt) });
|
|
44
44
|
return String(data.id);
|
|
45
45
|
}
|
|
@@ -52,11 +52,11 @@ async function resolveFirstInstallationId(jwt) {
|
|
|
52
52
|
return String(first.id);
|
|
53
53
|
}
|
|
54
54
|
async function fetchInstallationToken(jwt, installationId) {
|
|
55
|
-
const data = await fetchJson(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
const data = await fetchJson(`https://api.github.com/app/installations/${installationId}/access_tokens`, { method: "POST", headers: githubHeaders(jwt) });
|
|
56
|
+
return {
|
|
57
|
+
token: data.token,
|
|
58
|
+
...(typeof data.expires_at === "string" ? { expiresAt: data.expires_at } : {}),
|
|
59
|
+
};
|
|
60
60
|
}
|
|
61
61
|
export async function issueGitHubAppToken(credentials, options) {
|
|
62
62
|
const jwt = generateJwt(credentials.appId, credentials.privateKey);
|
|
@@ -64,7 +64,7 @@ export async function issueGitHubAppToken(credentials, options) {
|
|
|
64
64
|
?? (options?.repoFullName
|
|
65
65
|
? await resolveInstallationIdForRepo(jwt, options.repoFullName)
|
|
66
66
|
: await resolveFirstInstallationId(jwt));
|
|
67
|
-
const token = await fetchInstallationToken(jwt, installationId);
|
|
67
|
+
const { token } = await fetchInstallationToken(jwt, installationId);
|
|
68
68
|
return { installationId, token };
|
|
69
69
|
}
|
|
70
70
|
export function resolveGitHubAppCredentials(env = process.env) {
|
|
@@ -85,10 +85,7 @@ export function resolveGitHubAppCredentials(env = process.env) {
|
|
|
85
85
|
}
|
|
86
86
|
export function resolveGitHubAuthConfig(env = process.env) {
|
|
87
87
|
const app = resolveGitHubAppCredentials(env);
|
|
88
|
-
|
|
89
|
-
return { mode: "app", credentials: app };
|
|
90
|
-
}
|
|
91
|
-
return { mode: "none" };
|
|
88
|
+
return app ? { mode: "app", credentials: app } : { mode: "none" };
|
|
92
89
|
}
|
|
93
90
|
function unique(values) {
|
|
94
91
|
return [...new Set(values)];
|
|
@@ -97,38 +94,120 @@ export function createGitHubAppTokenManager(credentials, repoFullNames, logger)
|
|
|
97
94
|
let timer;
|
|
98
95
|
const repoInstallationIds = new Map();
|
|
99
96
|
const installationTokens = new Map();
|
|
97
|
+
const installationRefreshErrors = new Map();
|
|
98
|
+
const authFailures = [];
|
|
99
|
+
let lastRefreshAt = null;
|
|
100
|
+
let lastRefreshError = null;
|
|
101
|
+
function rememberInstallationRepo(installationId, repoFullName) {
|
|
102
|
+
repoInstallationIds.set(repoFullName, installationId);
|
|
103
|
+
}
|
|
104
|
+
function reposForInstallation(installationId) {
|
|
105
|
+
return repoFullNames.filter((repoFullName) => (repoInstallationIds.get(repoFullName) ?? credentials.installationId) === installationId);
|
|
106
|
+
}
|
|
107
|
+
function isTokenFresh(token) {
|
|
108
|
+
if (!token)
|
|
109
|
+
return false;
|
|
110
|
+
if (!token.expiresAt)
|
|
111
|
+
return true;
|
|
112
|
+
const expiresAtMs = Date.parse(token.expiresAt);
|
|
113
|
+
if (!Number.isFinite(expiresAtMs))
|
|
114
|
+
return false;
|
|
115
|
+
return expiresAtMs - Date.now() > TOKEN_EXPIRY_MARGIN_MS;
|
|
116
|
+
}
|
|
117
|
+
function recordRefreshSuccess(installationId, token) {
|
|
118
|
+
const refreshedAt = new Date().toISOString();
|
|
119
|
+
installationTokens.set(installationId, {
|
|
120
|
+
token: token.token,
|
|
121
|
+
...(token.expiresAt ? { expiresAt: token.expiresAt } : {}),
|
|
122
|
+
lastRefreshAt: refreshedAt,
|
|
123
|
+
lastRefreshError: null,
|
|
124
|
+
});
|
|
125
|
+
installationRefreshErrors.delete(installationId);
|
|
126
|
+
lastRefreshAt = refreshedAt;
|
|
127
|
+
lastRefreshError = null;
|
|
128
|
+
}
|
|
129
|
+
function recordRefreshFailure(installationId, message) {
|
|
130
|
+
lastRefreshError = message;
|
|
131
|
+
if (!installationId)
|
|
132
|
+
return;
|
|
133
|
+
installationRefreshErrors.set(installationId, message);
|
|
134
|
+
const existing = installationTokens.get(installationId);
|
|
135
|
+
if (existing) {
|
|
136
|
+
installationTokens.set(installationId, { ...existing, lastRefreshError: message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function resolveInstallationForRepo(jwt, repoFullName) {
|
|
140
|
+
const existing = repoInstallationIds.get(repoFullName) ?? credentials.installationId;
|
|
141
|
+
if (existing) {
|
|
142
|
+
rememberInstallationRepo(existing, repoFullName);
|
|
143
|
+
return existing;
|
|
144
|
+
}
|
|
145
|
+
const installationId = await resolveInstallationIdForRepo(jwt, repoFullName);
|
|
146
|
+
rememberInstallationRepo(installationId, repoFullName);
|
|
147
|
+
logger.info({ repoFullName, installationId }, "Resolved GitHub App installation for repo");
|
|
148
|
+
return installationId;
|
|
149
|
+
}
|
|
150
|
+
async function refreshInstallation(installationId, jwt, reason) {
|
|
151
|
+
const token = await fetchInstallationToken(jwt, installationId);
|
|
152
|
+
recordRefreshSuccess(installationId, token);
|
|
153
|
+
// Transparency: every rotation is logged so operators can see gh/git auth staying fresh.
|
|
154
|
+
logger.info({ installationId, expiresAt: token.expiresAt ?? null, reason }, "Rotated GitHub App installation token (gh + git authenticate as the bot with a fresh token)");
|
|
155
|
+
}
|
|
156
|
+
function escalateIfDegraded() {
|
|
157
|
+
const status = authStatus();
|
|
158
|
+
if (!status.ready) {
|
|
159
|
+
logger.error({ lastRefreshError: status.lastRefreshError, installations: status.installations }, "GitHub App auth is degraded — gh/git operations will fail until a fresh token is minted");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
100
162
|
async function refresh(options) {
|
|
101
163
|
const throwOnError = options?.throwOnError ?? false;
|
|
164
|
+
const reason = options?.reason ?? "scheduled";
|
|
102
165
|
try {
|
|
103
166
|
const jwt = generateJwt(credentials.appId, credentials.privateKey);
|
|
104
167
|
const installationIds = new Set();
|
|
105
|
-
if (credentials.installationId)
|
|
168
|
+
if (credentials.installationId)
|
|
106
169
|
installationIds.add(credentials.installationId);
|
|
107
|
-
}
|
|
108
170
|
for (const repoFullName of repoFullNames) {
|
|
109
|
-
|
|
110
|
-
if (!installationId) {
|
|
111
|
-
installationId = await resolveInstallationIdForRepo(jwt, repoFullName);
|
|
112
|
-
repoInstallationIds.set(repoFullName, installationId);
|
|
113
|
-
logger.info({ repoFullName, installationId }, "Resolved GitHub App installation for repo");
|
|
114
|
-
}
|
|
171
|
+
const installationId = await resolveInstallationForRepo(jwt, repoFullName);
|
|
115
172
|
installationIds.add(installationId);
|
|
116
173
|
}
|
|
117
174
|
for (const installationId of unique(installationIds)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
try {
|
|
176
|
+
await refreshInstallation(installationId, jwt, reason);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
180
|
+
recordRefreshFailure(installationId, message);
|
|
181
|
+
if (throwOnError)
|
|
182
|
+
throw error;
|
|
183
|
+
logger.warn({ installationId, error: message }, "Failed to refresh GitHub App installation token");
|
|
184
|
+
}
|
|
123
185
|
}
|
|
124
186
|
}
|
|
125
187
|
catch (error) {
|
|
126
188
|
const message = error instanceof Error ? error.message : String(error);
|
|
127
|
-
|
|
189
|
+
recordRefreshFailure(undefined, message);
|
|
190
|
+
if (throwOnError)
|
|
128
191
|
throw new Error(`Failed to initialize GitHub App auth: ${message}`);
|
|
129
|
-
}
|
|
130
192
|
logger.warn({ error: message }, "Failed to refresh GitHub App installation token");
|
|
131
193
|
}
|
|
194
|
+
finally {
|
|
195
|
+
escalateIfDegraded();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function refreshTokenForRepo(repoFullName, reason) {
|
|
199
|
+
const jwt = generateJwt(credentials.appId, credentials.privateKey);
|
|
200
|
+
let installationId;
|
|
201
|
+
try {
|
|
202
|
+
installationId = await resolveInstallationForRepo(jwt, repoFullName);
|
|
203
|
+
await refreshInstallation(installationId, jwt, reason);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
recordRefreshFailure(installationId, message);
|
|
208
|
+
logger.warn({ repoFullName, installationId, reason, error: message }, "Failed to refresh GitHub App installation token");
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
132
211
|
}
|
|
133
212
|
function schedule() {
|
|
134
213
|
timer = setTimeout(() => {
|
|
@@ -136,29 +215,83 @@ export function createGitHubAppTokenManager(credentials, repoFullNames, logger)
|
|
|
136
215
|
}, TOKEN_REFRESH_MS);
|
|
137
216
|
timer.unref?.();
|
|
138
217
|
}
|
|
218
|
+
function recentAuthFailures() {
|
|
219
|
+
const cutoff = Date.now() - RECENT_AUTH_FAILURE_WINDOW_MS;
|
|
220
|
+
return authFailures.filter((entry) => Date.parse(entry.at) >= cutoff);
|
|
221
|
+
}
|
|
222
|
+
function currentTokenForRepo(repoFullName) {
|
|
223
|
+
if (repoFullName) {
|
|
224
|
+
const installationId = repoInstallationIds.get(repoFullName) ?? credentials.installationId;
|
|
225
|
+
if (installationId) {
|
|
226
|
+
const token = installationTokens.get(installationId);
|
|
227
|
+
return token && isTokenFresh(token) ? token.token : undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (credentials.installationId) {
|
|
231
|
+
const token = installationTokens.get(credentials.installationId);
|
|
232
|
+
return token && isTokenFresh(token) ? token.token : undefined;
|
|
233
|
+
}
|
|
234
|
+
const first = installationTokens.values().next();
|
|
235
|
+
if (first.done)
|
|
236
|
+
return undefined;
|
|
237
|
+
const token = first.value;
|
|
238
|
+
return isTokenFresh(token) ? token.token : undefined;
|
|
239
|
+
}
|
|
240
|
+
function authStatus() {
|
|
241
|
+
const installationIds = unique([
|
|
242
|
+
...installationTokens.keys(),
|
|
243
|
+
...installationRefreshErrors.keys(),
|
|
244
|
+
...(credentials.installationId ? [credentials.installationId] : []),
|
|
245
|
+
...repoFullNames.flatMap((repoFullName) => {
|
|
246
|
+
const installationId = repoInstallationIds.get(repoFullName);
|
|
247
|
+
return installationId ? [installationId] : [];
|
|
248
|
+
}),
|
|
249
|
+
]);
|
|
250
|
+
const installations = installationIds.map((installationId) => {
|
|
251
|
+
const token = installationTokens.get(installationId);
|
|
252
|
+
const refreshError = token?.lastRefreshError ?? installationRefreshErrors.get(installationId) ?? null;
|
|
253
|
+
return {
|
|
254
|
+
installationId,
|
|
255
|
+
repoFullNames: reposForInstallation(installationId),
|
|
256
|
+
hasToken: Boolean(token),
|
|
257
|
+
expiresAt: token?.expiresAt ?? null,
|
|
258
|
+
lastRefreshAt: token?.lastRefreshAt ?? null,
|
|
259
|
+
lastRefreshError: refreshError,
|
|
260
|
+
fresh: isTokenFresh(token) && !refreshError,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
const failures = recentAuthFailures();
|
|
264
|
+
const missingRepoToken = repoFullNames.some((repoFullName) => !currentTokenForRepo(repoFullName));
|
|
265
|
+
const tokenRefreshFailing = installations.some((installation) => installation.lastRefreshError !== null && !installation.fresh);
|
|
266
|
+
return {
|
|
267
|
+
ready: !missingRepoToken && !tokenRefreshFailing,
|
|
268
|
+
lastRefreshAt,
|
|
269
|
+
lastRefreshError,
|
|
270
|
+
recentAuthFailureCount: failures.length,
|
|
271
|
+
lastAuthFailureAt: failures.at(-1)?.at ?? null,
|
|
272
|
+
installations,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
139
275
|
return {
|
|
140
276
|
async start() {
|
|
141
|
-
await refresh({ throwOnError: true });
|
|
277
|
+
await refresh({ throwOnError: true, reason: "startup" });
|
|
142
278
|
schedule();
|
|
143
279
|
},
|
|
144
280
|
stop() {
|
|
145
|
-
if (timer)
|
|
281
|
+
if (timer)
|
|
146
282
|
clearTimeout(timer);
|
|
147
|
-
|
|
148
|
-
}
|
|
283
|
+
timer = undefined;
|
|
149
284
|
},
|
|
150
|
-
currentTokenForRepo
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return installationTokens.get(credentials.installationId);
|
|
285
|
+
currentTokenForRepo,
|
|
286
|
+
async refreshTokenForRepo(repoFullName, reason) {
|
|
287
|
+
await refreshTokenForRepo(repoFullName, reason);
|
|
288
|
+
},
|
|
289
|
+
recordAuthFailure(repoFullName, message) {
|
|
290
|
+
authFailures.push({ repoFullName, message, at: new Date().toISOString() });
|
|
291
|
+
if (authFailures.length > MAX_AUTH_FAILURES_TO_KEEP) {
|
|
292
|
+
authFailures.splice(0, authFailures.length - MAX_AUTH_FAILURES_TO_KEEP);
|
|
159
293
|
}
|
|
160
|
-
const first = installationTokens.values().next();
|
|
161
|
-
return first.done ? undefined : first.value;
|
|
162
294
|
},
|
|
295
|
+
authStatus,
|
|
163
296
|
};
|
|
164
297
|
}
|