From b77f15856b27748d7f78e7fa753ba827490b4ff6 Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Mon, 19 Oct 2026 13:08:54 +0700 Subject: [PATCH 1/7] fix(ci): a deployment_status run never gates a closed or merged pull request readGithubRunContext matched the deployed commit against every pull request whose head is that commit, open or not, so a redeploy of a merged PR's head (or a platform deploying main after a rebase/fast-forward merge) was gated as that PR: credits spent and a github-pr-N environment cloned for a PR that no longer exists. Only an open pull request is a gate target, as the code's own comment and DOCUMENTATION.md say. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/github-context.test.ts | 25 +++++++++++++++++++++++++ src/lib/github-context.ts | 7 ++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/lib/github-context.test.ts b/src/lib/github-context.test.ts index b5c5944..766ade6 100644 --- a/src/lib/github-context.test.ts +++ b/src/lib/github-context.test.ts @@ -116,6 +116,31 @@ describe('readGithubRunContext', () => { }, ); expect(closed.kind).toBe('no_pull_request'); + // Nor is a merged/closed pull request whose head IS the deployed commit (a redeploy after merge). + for (const state of ['closed', 'merged']) { + const done = await readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { + ...eventFile({ deployment: { sha: SHA } }), + fetchImpl: githubApi([{ number: 7, state, head: { sha: SHA, ref: 'feat/done' } }]), + }, + ); + expect(done).toMatchObject({ kind: 'no_pull_request', event: 'deployment_status' }); + } + }); + + it('deployment_status: an open PR wins over a closed one with the same head commit', async () => { + const ctx = await readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { + ...eventFile({ deployment: { sha: SHA } }), + fetchImpl: githubApi([ + { number: 7, state: 'closed', head: { sha: SHA, ref: 'feat/old' } }, + { number: 9, state: 'open', head: { sha: SHA, ref: 'feat/new' } }, + ]), + }, + ); + expect(ctx).toMatchObject({ kind: 'pull_request', trigger: { prNumber: 9 } }); }); it('deployment_status: needs GITHUB_TOKEN, and a failed lookup never falls back to a guess', async () => { diff --git a/src/lib/github-context.ts b/src/lib/github-context.ts index 7153107..99d3b9a 100644 --- a/src/lib/github-context.ts +++ b/src/lib/github-context.ts @@ -89,9 +89,10 @@ export async function readGithubRunContext( { env, repoOwner, repoName, sha, token }, deps.fetchImpl ?? globalThis.fetch, ); - // The PR whose head IS this commit is the one this deployment belongs to. - // Anything looser (several open PRs, a merged one) is not a gate target. - const exact = pulls.filter(p => p.headSha === sha); + // The OPEN PR whose head IS this commit is the one this deployment belongs to. + // Anything looser (several open PRs, a merged or closed one) is not a gate target: + // a redeploy of a merged PR's head commit must not clone an environment for it. + const exact = pulls.filter(p => p.headSha === sha && p.state === 'open'); const open = pulls.filter(p => p.state === 'open'); const chosen = exact[0] ?? (open.length === 1 ? open[0] : undefined); if (!chosen) { From 99cb4b192e65c234cec5a7eff80575bee5d614a6 Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Mon, 19 Oct 2026 13:12:50 +0700 Subject: [PATCH 2/7] fix(ci): the deployment_status pull-request lookup has a timeout and survives a non-JSON reply The GET /commits/{sha}/pulls call had no signal, so a GitHub API (or proxy) that never answers hung ci run until the runner's job timeout, ignoring --request-timeout; and a 200 with an HTML body (proxy sign-in, captive portal) threw from response.json() as an uncaught exception (exit 1). The lookup now uses the resolved request timeout (flag, env, or the 30s default) and both cases are a typed CLIError with exit 10, like every other transient network failure. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/ci.ts | 6 ++++- src/lib/github-context.test.ts | 43 ++++++++++++++++++++++++++++++++ src/lib/github-context.ts | 45 +++++++++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/commands/ci.ts b/src/commands/ci.ts index 9e3eec2..fa5819a 100644 --- a/src/commands/ci.ts +++ b/src/commands/ci.ts @@ -5,6 +5,7 @@ import { Command } from 'commander'; import { makeHttpClient, parseRequestTimeoutFlag, + resolveRequestTimeoutMs, type CommonOptions as FactoryCommonOptions, } from '../lib/client-factory.js'; import { ApiError, CLIError, localValidationError } from '../lib/errors.js'; @@ -1327,7 +1328,10 @@ export async function runCiRun(opts: CiRunOptions, deps: TestDeps = {}): Promise throw err; } - const github = await readGithubRunContext(env, { fetchImpl: deps.fetchImpl }); + const github = await readGithubRunContext(env, { + fetchImpl: deps.fetchImpl, + requestTimeoutMs: resolveRequestTimeoutMs(opts, env), + }); let previewName: string | undefined; let githubTrigger: CiGithubTrigger | undefined; if (github.kind === 'pull_request') { diff --git a/src/lib/github-context.test.ts b/src/lib/github-context.test.ts index 766ade6..ddb92ab 100644 --- a/src/lib/github-context.test.ts +++ b/src/lib/github-context.test.ts @@ -143,6 +143,49 @@ describe('readGithubRunContext', () => { expect(ctx).toMatchObject({ kind: 'pull_request', trigger: { prNumber: 9 } }); }); + it('deployment_status: a lookup that never answers times out with exit 10 instead of hanging', async () => { + const hang = vi.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason)); + }), + ) as unknown as typeof globalThis.fetch; + const started = Date.now(); + await expect( + readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { + ...eventFile({ deployment: { sha: SHA } }), + fetchImpl: hang, + requestTimeoutMs: 50, + }, + ), + ).rejects.toMatchObject({ + exitCode: 10, + message: expect.stringContaining('did not answer within'), + }); + expect(Date.now() - started).toBeLessThan(5_000); + }); + + it('deployment_status: a 200 that is not JSON (proxy, captive portal) is exit 10, not an uncaught crash', async () => { + const html = vi.fn( + async () => + new Response('sign in to the proxy', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + ) as unknown as typeof globalThis.fetch; + await expect( + readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { + ...eventFile({ deployment: { sha: SHA } }), + fetchImpl: html, + }, + ), + ).rejects.toMatchObject({ exitCode: 10, message: expect.stringContaining('not JSON') }); + }); + it('deployment_status: needs GITHUB_TOKEN, and a failed lookup never falls back to a guess', async () => { await expect( readGithubRunContext( diff --git a/src/lib/github-context.ts b/src/lib/github-context.ts index 99d3b9a..b85a670 100644 --- a/src/lib/github-context.ts +++ b/src/lib/github-context.ts @@ -23,8 +23,13 @@ export interface GithubContextDeps { /** Reads `$GITHUB_EVENT_PATH`; injectable so tests never touch the runner. */ readFile?: (path: string) => string; fetchImpl?: FetchImpl; + /** Budget for the GitHub pull-request lookup, as `--request-timeout` gives every other call. */ + requestTimeoutMs?: number; } +/** Same default as the HttpClient's per-request budget when no flag or env var sets one. */ +const DEFAULT_LOOKUP_TIMEOUT_MS = 30_000; + /** The temporary environment a pull request's runs live in — shared with the GitHub App. */ export function previewEnvironmentName(prNumber: number): string { return `github-pr-${prNumber}`; @@ -86,7 +91,14 @@ export async function readGithubRunContext( ); } const pulls = await listPullsForCommit( - { env, repoOwner, repoName, sha, token }, + { + env, + repoOwner, + repoName, + sha, + token, + timeoutMs: deps.requestTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS, + }, deps.fetchImpl ?? globalThis.fetch, ); // The OPEN PR whose head IS this commit is the one this deployment belongs to. @@ -153,7 +165,14 @@ interface PullSummary { } async function listPullsForCommit( - args: { env: NodeJS.ProcessEnv; repoOwner: string; repoName: string; sha: string; token: string }, + args: { + env: NodeJS.ProcessEnv; + repoOwner: string; + repoName: string; + sha: string; + token: string; + timeoutMs: number; + }, fetchImpl: FetchImpl, ): Promise { const base = (args.env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''); @@ -167,10 +186,19 @@ async function listPullsForCommit( authorization: `Bearer ${args.token}`, 'x-github-api-version': '2022-11-28', }, + // Without a budget a GitHub API that never answers hangs the job until the runner kills it. + signal: AbortSignal.timeout(args.timeoutMs), }); } catch (err) { + const timedOut = + err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'); throw new CLIError( - `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: ${err instanceof Error ? err.message : String(err)}`, + `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: ` + + (timedOut + ? `GitHub did not answer within ${Math.round(args.timeoutMs / 1000)}s` + : err instanceof Error + ? err.message + : String(err)), 10, ); } @@ -183,7 +211,16 @@ async function listPullsForCommit( response.status === 401 || response.status === 403 ? 5 : 10, ); } - const body: unknown = await response.json(); + let body: unknown; + try { + body = await response.json(); + } catch { + // A proxy or captive portal answering 200 with HTML must be a transient failure, not an uncaught crash. + throw new CLIError( + `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: GitHub answered ${response.status} with a body that is not JSON`, + 10, + ); + } if (!Array.isArray(body)) return []; return body.flatMap((item): PullSummary[] => { const pr = asRecord(item); From 5b91a8ab1414a5c72fa100ca1ec9136ac2a7e434 Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Tue, 20 Oct 2026 10:06:12 +0700 Subject: [PATCH 3/7] fix(ci): only an open pull request whose head is the deployed commit is a gate target The single-open-PR fallback could still gate an open PR that merely contains the commit when the PR whose head it is was already merged (#550 scope). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/github-context.test.ts | 12 ++++++++++++ src/lib/github-context.ts | 10 ++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/github-context.test.ts b/src/lib/github-context.test.ts index ddb92ab..0c9c342 100644 --- a/src/lib/github-context.test.ts +++ b/src/lib/github-context.test.ts @@ -127,6 +127,18 @@ describe('readGithubRunContext', () => { ); expect(done).toMatchObject({ kind: 'no_pull_request', event: 'deployment_status' }); } + // Nor an open PR that only contains the commit (branched off it): its head is another commit. + const containing = await readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { + ...eventFile({ deployment: { sha: SHA } }), + fetchImpl: githubApi([ + { number: 7, state: 'closed', head: { sha: SHA, ref: 'feat/done' } }, + { number: 8, state: 'open', head: { sha: OTHER, ref: 'feat/next' } }, + ]), + }, + ); + expect(containing).toMatchObject({ kind: 'no_pull_request', event: 'deployment_status' }); }); it('deployment_status: an open PR wins over a closed one with the same head commit', async () => { diff --git a/src/lib/github-context.ts b/src/lib/github-context.ts index b85a670..ef872cb 100644 --- a/src/lib/github-context.ts +++ b/src/lib/github-context.ts @@ -101,12 +101,10 @@ export async function readGithubRunContext( }, deps.fetchImpl ?? globalThis.fetch, ); - // The OPEN PR whose head IS this commit is the one this deployment belongs to. - // Anything looser (several open PRs, a merged or closed one) is not a gate target: - // a redeploy of a merged PR's head commit must not clone an environment for it. - const exact = pulls.filter(p => p.headSha === sha && p.state === 'open'); - const open = pulls.filter(p => p.state === 'open'); - const chosen = exact[0] ?? (open.length === 1 ? open[0] : undefined); + // Only an OPEN PR whose head IS this commit is the one this deployment belongs to. + // Anything looser (an open PR that merely contains the commit, a merged or closed one) is + // not a gate target: a redeploy of a merged PR's head commit must not clone an environment. + const chosen = pulls.find(p => p.headSha === sha && p.state === 'open'); if (!chosen) { return { kind: 'no_pull_request', From 931e76f9c546937e669cab69424d4099e29dbad5 Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Tue, 20 Oct 2026 10:06:33 +0700 Subject: [PATCH 4/7] docs(changelog): #550 and #551 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e54360..ee3954b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for ## [Unreleased] +### Fixed + +- **`ci run` on `deployment_status` gates only an open pull request whose head is the deployed commit** (#550). A redeploy of a merged or closed pull request's head commit, or an open pull request that merely contains the commit, is now skipped with a `::notice::` and exit 0 (`no_pull_request`) instead of cloning an environment and gating it. +- **The `deployment_status` pull-request lookup honors `--request-timeout` and survives a non-JSON reply** (#551). A GitHub API that never answers is exit 10 within the request budget instead of hanging the job, and a 200 with an HTML body (proxy, captive portal) is exit 10 with a clear message instead of an uncaught exception. 401/403 keep exit 5. + ## [0.15.0] - 2026-10-17 ### Added From 5533f8872e88271be54188c7c6a2312e6535fe13 Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Tue, 20 Oct 2026 16:25:58 +0700 Subject: [PATCH 5/7] fix(ci): a lookup body that stalls past the timeout is reported as a timeout A 200 whose body never finishes aborts response.json() at the request timeout; that was reported as "a body that is not JSON". Both stay exit 10. Addresses review on #562. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/github-context.test.ts | 17 +++++++++++++++++ src/lib/github-context.ts | 18 ++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/lib/github-context.test.ts b/src/lib/github-context.test.ts index 0c9c342..ba74ea0 100644 --- a/src/lib/github-context.test.ts +++ b/src/lib/github-context.test.ts @@ -179,6 +179,23 @@ describe('readGithubRunContext', () => { expect(Date.now() - started).toBeLessThan(5_000); }); + it('deployment_status: a 200 whose body stalls past the timeout is reported as a timeout, exit 10', async () => { + const stall = vi.fn((_url: string | URL | Request, init?: RequestInit) => { + const body = new ReadableStream({ + start(controller) { + init?.signal?.addEventListener('abort', () => controller.error(init.signal?.reason)); + }, + }); + return Promise.resolve(new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })); + }) as unknown as typeof globalThis.fetch; + await expect( + readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { ...eventFile({ deployment: { sha: SHA } }), fetchImpl: stall, requestTimeoutMs: 50 }, + ), + ).rejects.toMatchObject({ exitCode: 10, message: expect.stringContaining('did not finish answering within') }); + }); + it('deployment_status: a 200 that is not JSON (proxy, captive portal) is exit 10, not an uncaught crash', async () => { const html = vi.fn( async () => diff --git a/src/lib/github-context.ts b/src/lib/github-context.ts index ef872cb..8670cb8 100644 --- a/src/lib/github-context.ts +++ b/src/lib/github-context.ts @@ -188,11 +188,9 @@ async function listPullsForCommit( signal: AbortSignal.timeout(args.timeoutMs), }); } catch (err) { - const timedOut = - err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'); throw new CLIError( `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: ` + - (timedOut + (isTimeout(err) ? `GitHub did not answer within ${Math.round(args.timeoutMs / 1000)}s` : err instanceof Error ? err.message @@ -212,10 +210,14 @@ async function listPullsForCommit( let body: unknown; try { body = await response.json(); - } catch { - // A proxy or captive portal answering 200 with HTML must be a transient failure, not an uncaught crash. + } catch (err) { + // A proxy or captive portal answering 200 with HTML must be a transient failure, not an uncaught crash; so is + // a body that stalls until the timeout aborts it, which is reported as the timeout it is. throw new CLIError( - `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: GitHub answered ${response.status} with a body that is not JSON`, + `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: ` + + (isTimeout(err) + ? `GitHub did not finish answering within ${Math.round(args.timeoutMs / 1000)}s` + : `GitHub answered ${response.status} with a body that is not JSON`), 10, ); } @@ -241,3 +243,7 @@ function asRecord(value: unknown): Record | undefined { ? (value as Record) : undefined; } + +function isTimeout(err: unknown): boolean { + return err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'); +} From 9efe574e820409147dc1f7adce3f15c377ec26c5 Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Tue, 20 Oct 2026 16:37:49 +0700 Subject: [PATCH 6/7] style: prettier Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/github-context.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/github-context.test.ts b/src/lib/github-context.test.ts index ba74ea0..3deb14c 100644 --- a/src/lib/github-context.test.ts +++ b/src/lib/github-context.test.ts @@ -186,14 +186,19 @@ describe('readGithubRunContext', () => { init?.signal?.addEventListener('abort', () => controller.error(init.signal?.reason)); }, }); - return Promise.resolve(new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve( + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), + ); }) as unknown as typeof globalThis.fetch; await expect( readGithubRunContext( actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), { ...eventFile({ deployment: { sha: SHA } }), fetchImpl: stall, requestTimeoutMs: 50 }, ), - ).rejects.toMatchObject({ exitCode: 10, message: expect.stringContaining('did not finish answering within') }); + ).rejects.toMatchObject({ + exitCode: 10, + message: expect.stringContaining('did not finish answering within'), + }); }); it('deployment_status: a 200 that is not JSON (proxy, captive portal) is exit 10, not an uncaught crash', async () => { From a8ff3a10828814e5296893776d34186a8ce50c4e Mon Sep 17 00:00:00 2001 From: Pugar Huda Mantoro Date: Wed, 21 Oct 2026 09:43:54 +0700 Subject: [PATCH 7/7] fix(ci): a lookup body that breaks off mid-stream is reported with its cause, not as "not JSON" Only a SyntaxError from response.json() is a body that is not JSON; a dropped connection or decode error now names its own cause, still exit 10 (CodeRabbit review on #562). --- src/lib/github-context.test.ts | 26 ++++++++++++++++++++++++++ src/lib/github-context.ts | 7 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/github-context.test.ts b/src/lib/github-context.test.ts index 3deb14c..6a5d540 100644 --- a/src/lib/github-context.test.ts +++ b/src/lib/github-context.test.ts @@ -220,6 +220,32 @@ describe('readGithubRunContext', () => { ).rejects.toMatchObject({ exitCode: 10, message: expect.stringContaining('not JSON') }); }); + it('deployment_status: a body that breaks off mid-stream is exit 10 with its cause, not "not JSON"', async () => { + const broken = vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('[{"number":')); + controller.error(new Error('socket hang up')); + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) as unknown as typeof globalThis.fetch; + const failure = readGithubRunContext( + actionsEnv({ GITHUB_EVENT_NAME: 'deployment_status', GITHUB_TOKEN: 't' }), + { ...eventFile({ deployment: { sha: SHA } }), fetchImpl: broken }, + ); + await expect(failure).rejects.toMatchObject({ + exitCode: 10, + message: expect.stringContaining('could not be read: socket hang up'), + }); + await expect(failure).rejects.not.toMatchObject({ + message: expect.stringContaining('not JSON'), + }); + }); + it('deployment_status: needs GITHUB_TOKEN, and a failed lookup never falls back to a guess', async () => { await expect( readGithubRunContext( diff --git a/src/lib/github-context.ts b/src/lib/github-context.ts index 8670cb8..83fdd42 100644 --- a/src/lib/github-context.ts +++ b/src/lib/github-context.ts @@ -212,12 +212,15 @@ async function listPullsForCommit( body = await response.json(); } catch (err) { // A proxy or captive portal answering 200 with HTML must be a transient failure, not an uncaught crash; so is - // a body that stalls until the timeout aborts it, which is reported as the timeout it is. + // a body that stalls until the timeout aborts it, which is reported as the timeout it is, and a connection that + // drops mid-body, which is reported with its own cause rather than as "not JSON". throw new CLIError( `Could not look up the pull request for commit ${args.sha.slice(0, 7)}: ` + (isTimeout(err) ? `GitHub did not finish answering within ${Math.round(args.timeoutMs / 1000)}s` - : `GitHub answered ${response.status} with a body that is not JSON`), + : err instanceof SyntaxError + ? `GitHub answered ${response.status} with a body that is not JSON` + : `GitHub's ${response.status} response body could not be read: ${err instanceof Error ? err.message : String(err)}`), 10, ); }