Skip to content

Commit 024b083

Browse files
naimish-browseclaude
authored andcommitted
test-companion: sign in as the GitHub App with Bearer, not through gh
gh sends its token as "Authorization: token ...", and GitHub accepts an App's JWT only as "Authorization: Bearer ...". So every App sign-in failed with "A JSON web token could not be decoded (HTTP 401)" and the Action fell back to github-actions[bot], even with the right App ID and key. Checked against the real App: through gh, 401; with Bearer, 200. The two calls made as the App (find the installation, get the token) now use fetch with Bearer. Everything after uses the installation token through gh as before. The two remaining silent fallbacks now say why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 33da358 commit 024b083

3 files changed

Lines changed: 87 additions & 49 deletions

File tree

‎test-companion/src/app-auth.mjs‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,36 @@ export function jwtClaims(appId, nowSeconds) {
2424
}
2525

2626
/**
27-
* GitHub's reason a `gh api` call failed, e.g. "Bad credentials (HTTP 401)", for the log.
28-
* gh's error output holds only the response message, never the token it sent.
27+
* Call the GitHub API as the App. Not through gh: gh sends `Authorization: token`,
28+
* and GitHub accepts an App's JWT only as `Authorization: Bearer`.
29+
* Returns { ok, status, data, reason }; `reason` is GitHub's message, never the JWT.
2930
*/
30-
export function ghFailureReason(result) {
31-
const line = String(result?.stderr ?? "")
32-
.split("\n")
33-
.map((l) => l.trim())
34-
.find(Boolean)
35-
return line ? line.replace(/^gh:\s*/, "") : "no detail from GitHub"
31+
export async function appApi(path, { jwt, method = "GET", body, apiUrl, fetchImpl = fetch } = {}) {
32+
const base = String(apiUrl || "https://api.github.com").replace(/\/+$/, "")
33+
let response
34+
try {
35+
response = await fetchImpl(`${base}/${path}`, {
36+
method,
37+
headers: {
38+
Authorization: `Bearer ${jwt}`,
39+
Accept: "application/vnd.github+json",
40+
"X-GitHub-Api-Version": "2022-11-28",
41+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
42+
},
43+
body: body === undefined ? undefined : JSON.stringify(body),
44+
})
45+
} catch (e) {
46+
return { ok: false, status: 0, data: undefined, reason: `could not reach GitHub: ${e?.message ?? e}` }
47+
}
48+
const data = await response.json().catch(() => undefined)
49+
const reason = response.ok ? "" : `${data?.message || response.statusText || "no detail from GitHub"} (HTTP ${response.status})`
50+
return { ok: response.ok, status: response.status, data, reason }
3651
}
3752

3853
/** What to log when looking up the App's installation on this repository fails. */
3954
export function installationLookupMessage(result) {
40-
const reason = ghFailureReason(result)
41-
if (/\bHTTP 404\b/.test(reason)) {
55+
const reason = result?.reason || "no detail from GitHub"
56+
if (result?.status === 404) {
4257
return { level: "notice", text: "Test Companion: the GitHub App is not installed on this repository; using the workflow token." }
4358
}
4459
return {

‎test-companion/src/main.mjs‎

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
appCredentials,
1919
signJwt,
2020
installationTokenRequest,
21-
ghFailureReason,
21+
appApi,
2222
installationLookupMessage,
2323
} from "./app-auth.mjs"
2424
import { OUTCOMES, parseSummary, resolveOutcome, checkConclusion, stepShouldFail, approvalWasSpent } from "./outcome.mjs"
@@ -168,7 +168,7 @@ function fetchAndCheckout(sha, branch) {
168168
}
169169

170170
/** A repo-scoped App installation token, or undefined to fall back to the workflow token. */
171-
function appInstallationToken() {
171+
async function appInstallationToken() {
172172
const creds = appCredentials(env)
173173
if (!creds) return undefined
174174
// Remove the key from the environment so nothing started later, the agent included, inherits it.
@@ -183,25 +183,22 @@ function appInstallationToken() {
183183
}
184184

185185
// The JWT authenticates the App; only the installation token can touch a repository.
186-
const asApp = (args) => runGh(args, { token: jwt })
186+
const asApp = (path, opts) => appApi(path, { jwt, apiUrl: env.GITHUB_API_URL, ...opts })
187187

188-
const found = asApp(["api", `repos/${REPO}/installation`])
189-
if (found.status !== 0) {
188+
const found = await asApp(`repos/${REPO}/installation`)
189+
if (!found.ok) {
190190
const { level, text } = installationLookupMessage(found)
191-
;(level === "warning" ? warn : notice)(text)
191+
if (level === "warning") warn(text)
192+
else notice(text)
192193
return undefined
193194
}
194-
let installationId
195-
let appLogin
196-
try {
197-
const installation = JSON.parse(found.stdout)
198-
installationId = installation?.id
199-
// Adopted only once a token is minted, so a failed mint can't leave us looking under the wrong login.
200-
appLogin = installation?.app_slug ? `${installation.app_slug}[bot]` : undefined
201-
} catch {
195+
const installationId = found.data?.id
196+
// Adopted only once a token is minted, so a failed mint can't leave us looking under the wrong login.
197+
const appLogin = found.data?.app_slug ? `${found.data.app_slug}[bot]` : undefined
198+
if (!installationId) {
199+
warn("Test Companion: GitHub's reply about the App's installation had no ID; using the workflow token.")
202200
return undefined
203201
}
204-
if (!installationId) return undefined
205202

206203
// Scoped to this repository; an empty request gets every repository the installation covers.
207204
const scope = installationTokenRequest(REPO)
@@ -210,29 +207,21 @@ function appInstallationToken() {
210207
return undefined
211208
}
212209

213-
const mint = (body) =>
214-
runGh(["api", `app/installations/${installationId}/access_tokens`, "--method", "POST", "--input", "-"], {
215-
token: jwt,
216-
input: JSON.stringify(body),
217-
})
218-
219210
// No retry with a smaller request: dropping the permission list asks for everything the installation holds.
220-
const minted = mint(scope)
221-
if (minted.status !== 0) {
211+
const minted = await asApp(`app/installations/${installationId}/access_tokens`, { method: "POST", body: scope })
212+
if (!minted.ok) {
222213
warn(
223-
`Could not get a GitHub App token with the permissions Test Companion needs (${ghFailureReason(minted)}); using the workflow token instead. ` +
214+
`Test Companion: could not get a GitHub App token with the permissions it needs (${minted.reason}); using the workflow token instead. ` +
224215
"If the App's permissions were changed, an organisation owner may need to accept them.",
225216
)
226217
return undefined
227218
}
228219

229-
let token
230-
try {
231-
token = JSON.parse(minted.stdout)?.token
232-
} catch {
220+
const token = minted.data?.token
221+
if (!token) {
222+
warn("Test Companion: GitHub's reply had no App token; using the workflow token.")
233223
return undefined
234224
}
235-
if (!token) return undefined
236225

237226
// Mask before first use.
238227
console.log(`::add-mask::${token}`)
@@ -388,7 +377,7 @@ async function main() {
388377
}
389378

390379
// Act as the App when installed, before anything else talks to GitHub.
391-
const appToken = appInstallationToken()
380+
const appToken = await appInstallationToken()
392381
if (appToken) {
393382
env.GH_TOKEN = appToken
394383
notice("Test Companion: acting as the GitHub App.")

‎test-companion/test/app-auth.test.mjs‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
jwtClaims,
77
signJwt,
88
installationTokenRequest,
9-
ghFailureReason,
9+
appApi,
1010
installationLookupMessage,
1111
} from "../src/app-auth.mjs"
1212

@@ -96,24 +96,58 @@ describe("installationTokenRequest", () => {
9696
})
9797
})
9898

99-
describe("App sign-in failures say why", () => {
100-
test("GitHub's reason is taken from gh's error output", () => {
101-
assert.equal(ghFailureReason({ stderr: "gh: Bad credentials (HTTP 401)\n" }), "Bad credentials (HTTP 401)")
99+
describe("appApi", () => {
100+
const fakeFetch = (status, json, seen) => async (url, init) => {
101+
Object.assign(seen, { url, init })
102+
return { ok: status >= 200 && status < 300, status, statusText: "", json: async () => json }
103+
}
104+
105+
test("sends the JWT as Bearer, which GitHub requires for an App", async () => {
106+
const seen = {}
107+
const r = await appApi("repos/o/r/installation", { jwt: "J", fetchImpl: fakeFetch(200, { id: 7 }, seen) })
108+
assert.equal(seen.init.headers.Authorization, "Bearer J")
109+
assert.equal(seen.url, "https://api.github.com/repos/o/r/installation")
110+
assert.deepEqual(r, { ok: true, status: 200, data: { id: 7 }, reason: "" })
111+
})
112+
113+
test("uses the server's API address when given", async () => {
114+
const seen = {}
115+
await appApi("x", { jwt: "J", apiUrl: "https://ghe.example/api/v3/", fetchImpl: fakeFetch(200, {}, seen) })
116+
assert.equal(seen.url, "https://ghe.example/api/v3/x")
117+
})
118+
119+
test("sends a JSON body on POST", async () => {
120+
const seen = {}
121+
await appApi("x", { jwt: "J", method: "POST", body: { a: 1 }, fetchImpl: fakeFetch(201, {}, seen) })
122+
assert.equal(seen.init.method, "POST")
123+
assert.equal(seen.init.body, '{"a":1}')
124+
assert.equal(seen.init.headers["Content-Type"], "application/json")
125+
})
126+
127+
test("a failure carries GitHub's message and status, never the JWT", async () => {
128+
const r = await appApi("x", { jwt: "SECRET-JWT", fetchImpl: fakeFetch(401, { message: "Bad credentials" }, {}) })
129+
assert.equal(r.ok, false)
130+
assert.equal(r.reason, "Bad credentials (HTTP 401)")
131+
assert.doesNotMatch(r.reason, /SECRET-JWT/)
102132
})
103133

104-
test("an empty error output still gives a reason", () => {
105-
assert.equal(ghFailureReason({ stderr: "" }), "no detail from GitHub")
106-
assert.equal(ghFailureReason(undefined), "no detail from GitHub")
134+
test("a network failure is reported, not thrown", async () => {
135+
const r = await appApi("x", { jwt: "J", fetchImpl: async () => { throw new Error("ECONNRESET") } })
136+
assert.equal(r.ok, false)
137+
assert.equal(r.status, 0)
138+
assert.match(r.reason, /ECONNRESET/)
107139
})
140+
})
108141

142+
describe("installationLookupMessage", () => {
109143
test("only a 404 means the App is not installed", () => {
110-
const m = installationLookupMessage({ stderr: "gh: Not Found (HTTP 404)" })
144+
const m = installationLookupMessage({ status: 404, reason: "Not Found (HTTP 404)" })
111145
assert.equal(m.level, "notice")
112146
assert.match(m.text, /not installed on this repository/)
113147
})
114148

115149
test("any other failure is a warning that carries GitHub's reason", () => {
116-
const m = installationLookupMessage({ stderr: "gh: A JSON web token could not be decoded (HTTP 401)" })
150+
const m = installationLookupMessage({ status: 401, reason: "A JSON web token could not be decoded (HTTP 401)" })
117151
assert.equal(m.level, "warning")
118152
assert.match(m.text, /A JSON web token could not be decoded \(HTTP 401\)/)
119153
assert.doesNotMatch(m.text, /not installed/)

0 commit comments

Comments
 (0)