fix(ci): filter finalized release proposals before pagination

This commit is contained in:
morluto committed 2026-10-24 08:22:13 +08:00
1 parent 4d88e4f07e
commit e389d6ea4e
2 files changed
+192 -2

No files matched your search

+29 -2
View File
@@ -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"
@@ -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;