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 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
  }
@@ -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>;
@@ -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
- method: "POST",
57
- headers: githubHeaders(jwt),
58
- });
59
- return data.token;
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
- if (app) {
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
- let installationId = repoInstallationIds.get(repoFullName) ?? credentials.installationId;
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
- const token = await fetchInstallationToken(jwt, installationId);
119
- installationTokens.set(installationId, token);
120
- }
121
- if (installationTokens.size === 0 && !credentials.installationId && repoFullNames.length === 0) {
122
- logger.warn("GitHub App auth configured, but no repositories are attached yet");
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
- if (throwOnError) {
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
- timer = undefined;
148
- }
283
+ timer = undefined;
149
284
  },
150
- currentTokenForRepo(repoFullName) {
151
- if (repoFullName) {
152
- const installationId = repoInstallationIds.get(repoFullName) ?? credentials.installationId;
153
- if (installationId) {
154
- return installationTokens.get(installationId);
155
- }
156
- }
157
- if (credentials.installationId) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {