From e389d6ea4ef56d8e03c98efc1e24a0bbf4267c83 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:54:36 +0800 Subject: [PATCH] fix(ci): filter finalized release proposals before pagination --- .github/workflows/release.yml | 31 +++- .../filesystem/releaseWorkflow.test.ts | 163 ++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bab5c35bf9..d62e713321 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,8 +47,35 @@ jobs: GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | - finalized=$(gh api --paginate "repos/${GH_REPO}/pulls?state=open&base=main&per_page=100" \ - --jq '.[] | select(.head.repo.full_name == .base.repo.full_name) | select(.head.ref | startswith("release-please--branches--main--")) | select(any(.labels[]; .name == "rea:release-notes-finalized")) | .number') + # Filter at the repository connection, without a search-index dependency. + pages=$(gh api graphql --paginate --slurp \ + -f owner="${GH_REPO%/*}" -f name="${GH_REPO#*/}" \ + -f query='query($owner:String!,$name:String!,$endCursor:String) { + repository(owner:$owner,name:$name) { + nameWithOwner + pullRequests(first:100,after:$endCursor,states:OPEN,baseRefName:"main",labels:["rea:release-notes-finalized"]) { + nodes { number headRefName headRepository { nameWithOwner } } + pageInfo { hasNextPage endCursor } + } + } + }') + finalized=$(jq -er ' + if type != "array" or length == 0 then error("Missing GitHub pages") else . end | + if .[-1].data.repository.pullRequests.pageInfo.hasNextPage != false + then error("Incomplete GitHub pagination") else . end | + map( + if (.errors // [] | length) != 0 then error("GitHub query failed") else . end | + .data.repository | + if (.nameWithOwner | type) != "string" or (.pullRequests.nodes | type) != "array" or + (.pullRequests.pageInfo.hasNextPage | type) != "boolean" + then error("Invalid GitHub repository connection") else . end | + .nameWithOwner as $repo | .pullRequests.nodes[] | + if (.number | type) != "number" or (.headRefName | type) != "string" or + (.headRepository != null and (.headRepository.nameWithOwner | type) != "string") + then error("Invalid GitHub pull request") else . end | + select(.headRepository.nameWithOwner == $repo) | + select(.headRefName | startswith("release-please--branches--main--")) | .number + ) | map(tostring) | join(" ")' <<< "$pages") if [[ -n "${finalized}" ]]; then echo "Finalized release notes on PR ${finalized}; automatic proposal refresh is paused." >> "$GITHUB_STEP_SUMMARY" echo "frozen=true" >> "$GITHUB_OUTPUT" diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index db841e74e0..b3c2ed2a13 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -138,6 +138,169 @@ it("keeps main pushes proposal-only and gates merged-release automation", async ); }); +const finalizedRelease = { + number: 1399, + headRefName: "release-please--branches--main--components--rea-agents", + headRepository: { nameWithOwner: "fixture/rea" }, +}; + +function editorialPage(nodes: unknown[], hasNextPage = false) { + return { + data: { + repository: { + nameWithOwner: "fixture/rea", + pullRequests: { + nodes, + pageInfo: { hasNextPage, endCursor: hasNextPage ? "next" : null }, + }, + }, + }, + }; +} + +it.each([ + { + name: "finalized release", + pages: [editorialPage([finalizedRelease])], + frozen: true, + }, + { name: "no finalized PR", pages: [editorialPage([])], frozen: false }, + { + name: "fork and unrelated head branch", + pages: [ + editorialPage([ + { ...finalizedRelease, headRepository: { nameWithOwner: "fork/rea" } }, + { + ...finalizedRelease, + headRefName: "release-please--branches--other--component", + }, + { ...finalizedRelease, headRepository: null }, + ]), + ], + frozen: false, + }, + { + name: "finalized release on a later page", + pages: [editorialPage([], true), editorialPage([finalizedRelease])], + frozen: true, + }, +])("preserves the proposal freeze for $name", async ({ pages, frozen }) => { + const step = (await readReleaseWorkflow()).jobs["release-proposal"].steps[0]; + const directory = await createTestTempDirectory("rea-release-editorial-"); + const output = join(directory, "output"); + const summary = join(directory, "summary"); + const args = join(directory, "args"); + await writeFile(output, ""); + await writeFile(summary, ""); + await writeFile(join(directory, "pages"), JSON.stringify(pages)); + await writeFile( + join(directory, "gh"), + '#!/bin/sh\nprintf "%s\n" "$@" > "$GH_ARGS"\ncat "$GH_PAGES"\n', + { mode: 0o755 }, + ); + await execFileAsync( + "bash", + ["-e", "-o", "pipefail", "-c", z.string().parse(step?.run)], + { + env: { + ...process.env, + PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, + GH_REPO: "fixture/rea", + GH_ARGS: args, + GH_PAGES: join(directory, "pages"), + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: summary, + }, + }, + ); + const request = await readFile(args, "utf8"); + expect(request).toContain("api\ngraphql\n--paginate\n--slurp"); + expect(request).toContain("owner=fixture"); + expect(request).toContain("name=rea"); + expect(request).toContain( + 'states:OPEN,baseRefName:"main",labels:["rea:release-notes-finalized"]', + ); + expect(request).toContain("after:$endCursor"); + expect(await readFile(output, "utf8")).toBe(frozen ? "frozen=true\n" : ""); + expect(await readFile(summary, "utf8")).toBe( + frozen + ? "Finalized release notes on PR 1399; automatic proposal refresh is paused.\n" + : "", + ); +}); + +it.each([ + { name: "transport quota failure", pages: [], status: 1 }, + { + name: "partial GraphQL error", + pages: [{ ...editorialPage([]), errors: [{ message: "quota exceeded" }] }], + status: 0, + }, + { + name: "unavailable repository", + pages: [{ data: { repository: null } }], + status: 0, + }, + { + name: "missing nodes", + pages: [ + { + data: { + repository: { nameWithOwner: "fixture/rea", pullRequests: {} }, + }, + }, + ], + status: 0, + }, + { + name: "malformed PR", + pages: [editorialPage([{ ...finalizedRelease, headRefName: null }])], + status: 0, + }, + { + name: "incomplete pagination", + pages: [editorialPage([], true)], + status: 0, + }, + { name: "empty response", pages: [], status: 0 }, +])("fails closed before release-please on $name", async ({ pages, status }) => { + const step = (await readReleaseWorkflow()).jobs["release-proposal"].steps[0]; + const directory = await createTestTempDirectory( + "rea-release-editorial-error-", + ); + const output = join(directory, "output"); + await writeFile(output, ""); + await writeFile(join(directory, "pages"), JSON.stringify(pages)); + await writeFile( + join(directory, "gh"), + `#!/bin/sh\ncat "$GH_PAGES"\nexit ${status}\n`, + { mode: 0o755 }, + ); + await expect( + execFileAsync( + "bash", + [ + "-e", + "-o", + "pipefail", + "-c", + `${z.string().parse(step?.run)}\nprintf 'action-ran\n' >> "$GITHUB_OUTPUT"`, + ], + { + env: { + ...process.env, + PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, + GH_REPO: "fixture/rea", + GH_PAGES: join(directory, "pages"), + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: join(directory, "summary"), + }, + }, + ), + ).rejects.toMatchObject({ code: expect.any(Number) }); + expect(await readFile(output, "utf8")).toBe(""); +}); + it("validates the reviewed merge before creating its release", async () => { const workflow = await readReleaseWorkflow(); const steps = workflow.jobs["merged-release"].steps;