Skip to content

Commit ccd8880

Browse files
committed
fix(ci): address CodeRabbit findings on PR quality gates
Treat removed new-form Version/OS headings as missing, soft-fail ancestry compares, tighten harness require/write allowlists, and clarify enforce-target is convention until branch protection.
1 parent 2d899e7 commit ccd8880

9 files changed

Lines changed: 127 additions & 42 deletions

File tree

.github/scripts/issue-quality.cjs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -704,11 +704,11 @@ function validateIssue(issue) {
704704
} else if (
705705
!softPass &&
706706
isNewBugForm &&
707-
version !== null &&
708-
(isEmpty(version) || isRawPlaceholder(version))
707+
(version === null || isEmpty(version) || isRawPlaceholder(version))
709708
) {
710-
// New form requires Version. Legacy N/A / No response soft-pass stays
711-
// only for bodies without Client or integration.
709+
// New form requires Version (including when the heading was removed).
710+
// Legacy N/A / No response soft-pass stays only for bodies without
711+
// Client or integration.
712712
reasons.push("Version is missing.");
713713
guidance.push("Add your OpenCodex version so we can reproduce the environment.");
714714
}
@@ -719,8 +719,7 @@ function validateIssue(issue) {
719719
} else if (
720720
!softPass &&
721721
isNewBugForm &&
722-
os !== null &&
723-
(isEmpty(os) || isRawPlaceholder(os))
722+
(os === null || isEmpty(os) || isRawPlaceholder(os))
724723
) {
725724
reasons.push("Operating system is missing.");
726725
guidance.push("Add your OS name and version (for example Windows 11 24H2).");

.github/scripts/issue-quality.test.cjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,46 @@ describe("validateIssue - bug", () => {
799799
assert.ok(result.reasons.some((r) => /Operating system/i.test(r)));
800800
});
801801

802+
it("rejects a new-form bug when the Version heading was removed", () => {
803+
const body = [
804+
"### Client or integration",
805+
"Codex CLI",
806+
"### Area",
807+
"CLI",
808+
"### Summary",
809+
"Proxy returns 502 when streaming is enabled on Windows.",
810+
"### Reproduction",
811+
"1. ocx start",
812+
"2. Send a streaming /v1/responses request",
813+
"### Operating system",
814+
"Windows 11",
815+
].join("\n");
816+
const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] });
817+
assert.equal(result.kind, "bug");
818+
assert.equal(result.valid, false);
819+
assert.ok(result.reasons.some((r) => /Version/i.test(r) && /missing/i.test(r)));
820+
});
821+
822+
it("rejects a new-form bug when the Operating system heading was removed", () => {
823+
const body = [
824+
"### Client or integration",
825+
"Codex CLI",
826+
"### Area",
827+
"CLI",
828+
"### Summary",
829+
"Proxy returns 502 when streaming is enabled on Windows.",
830+
"### Reproduction",
831+
"1. ocx start",
832+
"2. Send a streaming /v1/responses request",
833+
"### Version",
834+
"2.7.42",
835+
].join("\n");
836+
const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] });
837+
assert.equal(result.kind, "bug");
838+
assert.equal(result.valid, false);
839+
assert.ok(result.reasons.some((r) => /Operating system/i.test(r) && /missing/i.test(r)));
840+
});
841+
802842
it("rejects a new-form bug whose Reproduction is only a vague phrase", () => {
803843
const body = [
804844
"### Client or integration",

.github/scripts/pr-quality.cjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,18 @@ function collectPrQualityFailures({
8484
behindBase,
8585
authorPermission,
8686
permissionLookupFailed = false,
87+
ancestryLookupFailed = false,
8788
}) {
8889
const failures = [];
8990
const wrongBase = !allowedBases.includes(baseRef);
9091
if (wrongBase) {
9192
failures.push({ code: "wrong_base" });
9293
} else {
94+
// Permission lookup fails closed (still evaluate ancestry). Compare API
95+
// failures skip ancestry — zeros would falsely pass the #644 heuristic.
9396
const skipAncestry =
94-
!permissionLookupFailed && authorHasPushPermission(authorPermission);
97+
ancestryLookupFailed ||
98+
(!permissionLookupFailed && authorHasPushPermission(authorPermission));
9599
if (!skipAncestry && isWrongAncestry({ behindMain, behindBase })) {
96100
failures.push({ code: "wrong_ancestry" });
97101
}

.github/scripts/pr-quality.test.cjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,24 @@ describe("collectPrQualityFailures", () => {
170170
});
171171
assert.ok(failures.some((f) => f.code === "wrong_ancestry"));
172172
});
173+
174+
it("skips ancestry when compare lookup failed (cannot evaluate)", () => {
175+
const failures = collectPrQualityFailures({
176+
baseRef: "dev",
177+
allowedBases: allowed,
178+
body: [
179+
"## Summary",
180+
"This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.",
181+
"",
182+
"## Test plan",
183+
"- Launch the tray app after setting CODEX_HOME",
184+
"- Confirm the listener and launcher use the same workspace root",
185+
].join("\n"),
186+
behindMain: 0,
187+
behindBase: 0,
188+
authorPermission: "read",
189+
ancestryLookupFailed: true,
190+
});
191+
assert.ok(!failures.some((f) => f.code === "wrong_ancestry"));
192+
});
173193
});

.github/workflows/enforce-pr-target.yml

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -277,25 +277,33 @@ jobs:
277277
278278
let behindMain = 0;
279279
let behindBase = 0;
280+
let ancestryLookupFailed = false;
280281
const baseAllowed = ALLOWED_BASES.includes(pr.base.ref);
281282
282283
if (baseAllowed) {
283284
const headSha = pr.head.sha;
284-
const { data: mainCompare } =
285-
await github.rest.repos.compareCommitsWithBasehead({
286-
owner,
287-
repo,
288-
basehead: `main...${headSha}`
289-
});
290-
behindMain = mainCompare.behind_by;
291-
292-
const { data: baseCompare } =
293-
await github.rest.repos.compareCommitsWithBasehead({
294-
owner,
295-
repo,
296-
basehead: `${pr.base.ref}...${headSha}`
297-
});
298-
behindBase = baseCompare.behind_by;
285+
try {
286+
const { data: mainCompare } =
287+
await github.rest.repos.compareCommitsWithBasehead({
288+
owner,
289+
repo,
290+
basehead: `main...${headSha}`
291+
});
292+
behindMain = mainCompare.behind_by;
293+
294+
const { data: baseCompare } =
295+
await github.rest.repos.compareCommitsWithBasehead({
296+
owner,
297+
repo,
298+
basehead: `${pr.base.ref}...${headSha}`
299+
});
300+
behindBase = baseCompare.behind_by;
301+
} catch (error) {
302+
ancestryLookupFailed = true;
303+
core.warning(
304+
`Could not compare commits for ancestry check: ${error.message}`
305+
);
306+
}
299307
}
300308
301309
const failures = collectPrQualityFailures({
@@ -305,7 +313,8 @@ jobs:
305313
behindMain,
306314
behindBase,
307315
authorPermission,
308-
permissionLookupFailed
316+
permissionLookupFailed,
317+
ancestryLookupFailed
309318
});
310319
311320
if (failures.length > 0) {

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,12 @@ integration line to another, or rebasing a stale branch onto the current head,
8080
is ordinary maintenance rather than noise — open it as a normal pull request
8181
and name the source commits in the description.
8282

83-
The required **`enforce-target`** CI check rejects pull requests whose head
83+
The **`enforce-target`** CI check rejects pull requests whose head
8484
ancestry sits on the **`main`** tip while far behind **`dev`** or **`dev2-go`**,
8585
and rejects empty, thin, or malformed descriptions; authors with repository
86-
push permission skip the ancestry heuristic only.
86+
push permission skip the ancestry heuristic only. As with approval requirements
87+
in [`MAINTAINERS.md`](./MAINTAINERS.md), this is enforced by convention until
88+
branch protection is configured.
8789

8890
[`MAINTAINERS.md`](./MAINTAINERS.md) is authoritative for review and merge
8991
policy (approvals, CI requirements, security review, promotion). This file

MAINTAINERS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ rule is written out under [Review and merge policy](#review-and-merge-policy).
4848
`dev2-go` before closing out the `dev` merge, label it `needs-go-port`,
4949
and name the source commits. That label is the durable signal that a
5050
deferred port is intentional, not forgotten.
51-
- The required **`enforce-target`** CI check rejects pull requests whose head
51+
- The **`enforce-target`** CI check rejects pull requests whose head
5252
ancestry sits on the **`main`** tip while far behind **`dev`** or **`dev2-go`**,
5353
and rejects empty, thin, or malformed descriptions; authors with repository
54-
push permission skip the ancestry heuristic only.
54+
push permission skip the ancestry heuristic only. As with the approval
55+
requirement above, this is enforced by convention until branch protection is
56+
configured (see the note under the change log).
5557
- A pull request requires approval from at least one maintainer and successful required CI checks
5658
before merge.
5759
- Authors do not approve their own pull requests.

tests/ci-workflows.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,13 @@ describe("GitHub Actions hardening", () => {
713713
// nobody reviewed.
714714
const restWrites = [...script.matchAll(/github\.rest\.[\w.]+/g)]
715715
.map(match => match[0])
716-
.filter(name => !name.endsWith(".get") && !name.endsWith(".listComments") && !name.includes(".repos."));
716+
.filter(
717+
name =>
718+
!name.endsWith(".get") &&
719+
!name.endsWith(".listComments") &&
720+
name !== "github.rest.repos.getCollaboratorPermissionLevel" &&
721+
name !== "github.rest.repos.compareCommitsWithBasehead",
722+
);
717723
expect([...new Set(restWrites)].sort()).toEqual([
718724
"github.rest.issues.createComment",
719725
"github.rest.issues.updateComment",

tests/helpers/enforce-pr-target-harness.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,11 @@ export async function runEnforcePrTarget(
398398
const outputs: { name: string; value: unknown }[] = [];
399399
const states = new Map<string, unknown>();
400400
const failOn = new Set(options.failOn ?? []);
401+
if (options.failPermissionLookup) {
402+
// Route through `record` so the call appears in the recording even when it
403+
// rejects (same semantics as `failOn`).
404+
failOn.add("repos.getCollaboratorPermissionLevel");
405+
}
401406
const failStatus = options.failStatus ?? 500;
402407

403408
const pr = {
@@ -452,19 +457,21 @@ export async function runEnforcePrTarget(
452457

453458
const nodeRequire = createRequire(path.join(process.cwd(), "package.json"));
454459
const scriptsRoot = path.resolve(process.cwd(), ".github", "scripts");
460+
/** Bare modules the workflow script may load (see enforce-pr-target.yml). */
461+
const ALLOWED_MODULES = new Set(["path", "node:path"]);
455462

456463
function scopedRequire(id: string) {
457464
calls.push({ method: "require", args: [id] });
458-
const isRelative = id.startsWith(".") || id.startsWith("/") || path.isAbsolute(id);
459-
if (!isRelative) {
465+
const isPathLike = id.startsWith(".") || path.isAbsolute(id);
466+
if (!isPathLike) {
467+
if (!ALLOWED_MODULES.has(id)) {
468+
throw new Error(`the script must not require ${id}`);
469+
}
460470
return nodeRequire(id);
461471
}
462-
const resolved = path.isAbsolute(id) ? id : path.resolve(process.cwd(), id);
472+
const resolved = path.isAbsolute(id) ? path.resolve(id) : path.resolve(process.cwd(), id);
463473
if (!resolved.startsWith(scriptsRoot + path.sep) && resolved !== scriptsRoot) {
464-
const norm = resolved.replace(/\\/g, "/");
465-
if (!norm.includes("/.github/scripts/")) {
466-
throw new Error(`the script must not require ${id}`);
467-
}
474+
throw new Error(`the script must not require ${id}`);
468475
}
469476
return nodeRequire(resolved);
470477
}
@@ -494,14 +501,10 @@ export async function runEnforcePrTarget(
494501
updateComment: (args: unknown) => respond("issues.updateComment", args, { id: 7 }),
495502
},
496503
repos: {
497-
getCollaboratorPermissionLevel: (args: unknown) => {
498-
if (options.failPermissionLookup) {
499-
return Promise.reject(octokitError("repos.getCollaboratorPermissionLevel", failStatus));
500-
}
501-
return respond("repos.getCollaboratorPermissionLevel", args, {
504+
getCollaboratorPermissionLevel: (args: unknown) =>
505+
respond("repos.getCollaboratorPermissionLevel", args, {
502506
permission: options.authorPermission ?? "read",
503-
});
504-
},
507+
}),
505508
compareCommitsWithBasehead: (args: unknown) => {
506509
const basehead = String((args as { basehead?: string })?.basehead ?? "");
507510
return respond("repos.compareCommitsWithBasehead", args, compareResult(basehead));

0 commit comments

Comments
 (0)