Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions .github/scripts/enforce-pr-target.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ const { describe, it } = require("node:test");
const assert = require("node:assert/strict");

describe("enforce-pr-target workflow", () => {
const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml");
const workflowPath = path.join(
__dirname,
"../workflows/enforce-pr-target.yml",
);
const workflow = fs.readFileSync(workflowPath, "utf8");

it("uses pull_request_target without checking out PR head code", () => {
Expand All @@ -23,8 +26,13 @@ describe("enforce-pr-target workflow", () => {
// "Resource not accessible by integration" when contents stays unset/read
// (seen on #626). Assert the real permissions block, not comment text
// that also mentions these scopes.
const permissionsBlock = workflow.match(/^permissions:\n((?:[ \t]+.+\n)+)/m);
assert.ok(permissionsBlock, "workflow must declare a top-level permissions block");
const permissionsBlock = workflow.match(
/^permissions:\n((?:[ \t]+.+\n)+)/m,
);
assert.ok(
permissionsBlock,
"workflow must declare a top-level permissions block",
);
const lines = permissionsBlock[1]
.split("\n")
.map((line) => line.trim())
Expand All @@ -50,10 +58,16 @@ describe("enforce-pr-target workflow", () => {

it("checks out trusted default-branch scripts only (never PR head)", () => {
assert.match(workflow, /actions\/checkout@[0-9a-f]{40}/);
assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/);
assert.match(
workflow,
/ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/,
);
assert.match(workflow, /sparse-checkout:\s*\.github\/scripts/);
assert.match(workflow, /persist-credentials:\s*false/);
assert.doesNotMatch(workflow, /ref:\s*\$\{\{\s*github\.event\.pull_request\.head/);
assert.doesNotMatch(
workflow,
/ref:\s*\$\{\{\s*github\.event\.pull_request\.head/,
);
});

it("loads pr-quality via require from the checked-out scripts", () => {
Expand All @@ -70,8 +84,11 @@ describe("enforce-pr-target workflow", () => {
);
assert.ok(qualityCall, "must call collectPrQualityFailures");
assert.match(qualityCall[1], /stackedBase/);
assert.match(qualityCall[1], /headFromSameRepo/);
assert.match(qualityCall[1], /isSameGithubRepo/);
// The retired dev-promotion exception is gone: no same-repo `dev` head
// may ever be special-cased again, so these must stay absent.
assert.doesNotMatch(qualityCall[1], /headFromSameRepo/);
assert.doesNotMatch(qualityCall[1], /isSameGithubRepo/);
assert.doesNotMatch(qualityCall[1], /headRef/);
});

it("strips stale WRONG BRANCH prefix on failure when base is corrected", () => {
Expand Down
55 changes: 13 additions & 42 deletions .github/scripts/pr-quality.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ function isWrongAncestry({
aheadMainMax = ANCESTRY_AHEAD_MAIN_MAX,
}) {
return (
behindMain === 0 &&
behindBase >= threshold &&
aheadMain <= aheadMainMax
behindMain === 0 && behindBase >= threshold && aheadMain <= aheadMainMax
);
}

function authorHasPushPermission(permission) {
return permission === "admin" || permission === "maintain" || permission === "write";
return (
permission === "admin" ||
permission === "maintain" ||
permission === "write"
);
}

/**
Expand Down Expand Up @@ -103,7 +105,9 @@ function assessPrDescription(body) {
const cleaned = clean(withoutTemplate);
if (!cleaned) {
// Unterminated comment tails removed as well — see stripHtmlComments in issue-quality.cjs.
const strippedComments = withoutTemplate.replace(/<!--[\s\S]*?(?:-->|$)/g, "").trim();
const strippedComments = withoutTemplate
.replace(/<!--[\s\S]*?(?:-->|$)/g, "")
.trim();
if (!strippedComments) return { ok: false, reason: "empty" };
if (isPlaceholderOnlyValue(strippedComments)) {
return { ok: false, reason: "placeholder" };
Expand All @@ -113,7 +117,9 @@ function assessPrDescription(body) {
if (isPlaceholderOnlyValue(cleaned)) {
return { ok: false, reason: "placeholder" };
}
if (hasSubstantialStructuredContent(cleaned, MIN_SECTION_LEN, MIN_RICH_SECTIONS)) {
if (
hasSubstantialStructuredContent(cleaned, MIN_SECTION_LEN, MIN_RICH_SECTIONS)
) {
return { ok: true };
}
if (
Expand All @@ -125,34 +131,6 @@ function assessPrDescription(body) {
return { ok: false, reason: "thin" };
}

/**
* Fail-closed same-repository check. A fork can name its head `dev`; that is
* not a maintainer promotion. Prefer numeric GitHub repo ids; fall back to
* `full_name`, then owner/name. Missing fields never compare equal.
*/
function isSameGithubRepo(headRepo, baseRepo) {
if (!headRepo || !baseRepo || typeof headRepo !== "object" || typeof baseRepo !== "object") {
return false;
}
if (typeof headRepo.id === "number" && typeof baseRepo.id === "number") {
return headRepo.id === baseRepo.id;
}
const headFull = typeof headRepo.full_name === "string" ? headRepo.full_name : "";
const baseFull = typeof baseRepo.full_name === "string" ? baseRepo.full_name : "";
if (headFull && baseFull) {
return headFull === baseFull;
}
const headOwner = headRepo.owner && headRepo.owner.login;
const baseOwner = baseRepo.owner && baseRepo.owner.login;
return Boolean(
headOwner &&
baseOwner &&
headOwner === baseOwner &&
headRepo.name &&
headRepo.name === baseRepo.name,
);
}

function collectPrQualityFailures({
baseRef,
allowedBases,
Expand All @@ -165,15 +143,9 @@ function collectPrQualityFailures({
ancestryLookupFailed = false,
/** True when baseRef is another open PR's head (stacked child). */
stackedBase = false,
/** PR head ref. Promotion also requires same-repository head. */
headRef,
/** True only when head and base resolve to the same GitHub repository. */
headFromSameRepo = false,
}) {
const failures = [];
const promotionBase =
baseRef === "main" && headRef === "dev" && headFromSameRepo === true;
const wrongBase = !allowedBases.includes(baseRef) && !stackedBase && !promotionBase;
const wrongBase = !allowedBases.includes(baseRef) && !stackedBase;
if (wrongBase) {
failures.push({ code: "wrong_base" });
} else {
Expand Down Expand Up @@ -204,7 +176,6 @@ module.exports = {
ANCESTRY_BEHIND_THRESHOLD,
ANCESTRY_AHEAD_MAIN_MAX,
isWrongAncestry,
isSameGithubRepo,
authorHasPushPermission,
assessPrDescription,
collectPrQualityFailures,
Expand Down
81 changes: 24 additions & 57 deletions .github/scripts/pr-quality.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const {
authorHasPushPermission,
assessPrDescription,
collectPrQualityFailures,
isSameGithubRepo,
} = require("./pr-quality.cjs");

describe("isWrongAncestry", () => {
Expand All @@ -21,12 +20,21 @@ describe("isWrongAncestry", () => {

it("uses threshold 20 by default", () => {
assert.equal(ANCESTRY_BEHIND_THRESHOLD, 20);
assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 20, aheadMain: 1 }), true);
assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 19, aheadMain: 1 }), false);
assert.equal(
isWrongAncestry({ behindMain: 0, behindBase: 20, aheadMain: 1 }),
true,
);
assert.equal(
isWrongAncestry({ behindMain: 0, behindBase: 19, aheadMain: 1 }),
false,
);
});

it("passes when head is behind main (not sitting on main tip)", () => {
assert.equal(isWrongAncestry({ behindMain: 1, behindBase: 44, aheadMain: 1 }), false);
assert.equal(
isWrongAncestry({ behindMain: 1, behindBase: 44, aheadMain: 1 }),
false,
);
});

it("passes stale dev-based branches that are many commits ahead of main", () => {
Expand All @@ -53,7 +61,9 @@ describe("assessPrDescription", () => {
assert.equal(assessPrDescription("").ok, false);
assert.equal(assessPrDescription(" ").ok, false);
assert.equal(
assessPrDescription("<!-- release notes by coderabbit.ai -->\n\n<!-- end -->").reason,
assessPrDescription(
"<!-- release notes by coderabbit.ai -->\n\n<!-- end -->",
).reason,
"empty",
);
});
Expand Down Expand Up @@ -122,7 +132,8 @@ describe("collectPrQualityFailures", () => {
const failures = collectPrQualityFailures({
baseRef: "main",
allowedBases: allowed,
body: "## Summary\n" + "x".repeat(50) + "\n\n## Test plan\n" + "y".repeat(50),
body:
"## Summary\n" + "x".repeat(50) + "\n\n## Test plan\n" + "y".repeat(50),
behindMain: 0,
behindBase: 0,
authorPermission: "read",
Expand Down Expand Up @@ -279,11 +290,13 @@ describe("collectPrQualityFailures", () => {
assert.ok(failures.some((f) => f.code === "wrong_base"));
});

it("does not flag wrong_base for same-repo maintainer promotion main + head dev", () => {
it("no longer special-cases a promotion-shaped main + head dev PR", () => {
// The dev → main promotion exception was removed with the retired branch.
// With `main` outside this test's allow-list, a promotion-shaped PR is an
// ordinary wrong_base like any other non-allow-listed base — the head name
// never gets special treatment again.
const failures = collectPrQualityFailures({
baseRef: "main",
headRef: "dev",
headFromSameRepo: true,
allowedBases: allowed,
body: [
"## Summary",
Expand All @@ -297,15 +310,13 @@ describe("collectPrQualityFailures", () => {
behindBase: 0,
authorPermission: "write",
});
assert.ok(!failures.some((f) => f.code === "wrong_base"));
assert.ok(failures.some((f) => f.code === "wrong_base"));
assert.ok(!failures.some((f) => f.code === "wrong_ancestry"));
});

it("still flags wrong_base for a fork head named dev targeting main", () => {
it("still flags wrong_base for a head named dev on a non-allow-listed base", () => {
const failures = collectPrQualityFailures({
baseRef: "main",
headRef: "dev",
headFromSameRepo: false,
allowedBases: allowed,
body: [
"## Summary",
Expand All @@ -325,7 +336,6 @@ describe("collectPrQualityFailures", () => {
it("still flags wrong_base for main + head other", () => {
const failures = collectPrQualityFailures({
baseRef: "main",
headRef: "feat/other",
allowedBases: allowed,
body: [
"## Summary",
Expand All @@ -342,46 +352,3 @@ describe("collectPrQualityFailures", () => {
assert.ok(failures.some((f) => f.code === "wrong_base"));
});
});

describe("isSameGithubRepo", () => {
it("matches numeric ids and rejects a fork id", () => {
assert.equal(isSameGithubRepo({ id: 1 }, { id: 1 }), true);
assert.equal(isSameGithubRepo({ id: 1 }, { id: 2 }), false);
});

it("does not treat missing ids as equal", () => {
assert.equal(isSameGithubRepo({}, {}), false);
assert.equal(isSameGithubRepo(null, { id: 1 }), false);
});

it("falls back to full_name then owner/name", () => {
assert.equal(
isSameGithubRepo(
{ full_name: "GroepOnline/opencodex" },
{ full_name: "GroepOnline/opencodex" },
),
true,
);
assert.equal(
isSameGithubRepo(
{ full_name: "fork/opencodex" },
{ full_name: "GroepOnline/opencodex" },
),
false,
);
assert.equal(
isSameGithubRepo(
{ name: "opencodex", owner: { login: "GroepOnline" } },
{ name: "opencodex", owner: { login: "GroepOnline" } },
),
true,
);
assert.equal(
isSameGithubRepo(
{ name: "opencodex", owner: { login: "contributor" } },
{ name: "opencodex", owner: { login: "GroepOnline" } },
),
false,
);
});
});
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ on:
# tests/ci-workflows.test.ts without anything noticing.
- ".github/workflows/**"
push:
branches: [main, preview, dev]
branches: [main, preview]
paths:
- "src/**"
- "bin/**"
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ name: Container image
# release.yml creates the tag with GITHUB_TOKEN, and tag pushes made with that
# token never start a `push` run; release.yml therefore dispatches this workflow
# on the exact tag so the GHCR image carries the release identity (SHA + version).
# Pull requests, push to dev, and off-main branch dispatch build-and-load. They do not push.
# Pull requests and off-main branch dispatch build-and-load. They do not push.
# packages permissions stay static literals. actionlint rejects expressions there.
on:
pull_request:
branches: [main, dev]
branches: [main]
push:
branches: [dev]
tags:
- "v*.*.*"
workflow_dispatch:
Expand All @@ -31,7 +30,7 @@ concurrency:

jobs:
image:
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/dev') || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/enforce-issue-quality.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Enforce issue quality

# Issue events always load this workflow from the repository DEFAULT branch
# (currently `main`), not from `dev`. Landing here on `dev` alone does not
# (currently `main`). Landing here on a non-default branch alone does not
# change live issue-quality behavior until the change is also on that default
# branch.
on:
Expand Down
11 changes: 4 additions & 7 deletions .github/workflows/enforce-pr-target.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,14 @@ jobs:
with:
script: |
const path = require("path");
const { collectPrQualityFailures, isSameGithubRepo } = require(
const { collectPrQualityFailures } = require(
path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"),
);

// PRs target `main`, the integration branch. `dev` is not an
// allowed feature-PR base. Maintainer promotion remains an
// explicit leftover exception in collectPrQualityFailures: base
// main + head dev on the same repository. Stacked children that
// target another open PR head are also exempt.
// allowed feature-PR base; the retired `dev` lane and its
// promotion exception were removed with the branch. Stacked
// children that target another open PR head are exempt.
// (See tests/ci-workflows.test.ts — the allow-list is pinned there.)
const DEFAULT_BASE = "main";
const ALLOWED_BASES = ["main"];
Expand Down Expand Up @@ -349,8 +348,6 @@ jobs:

const failures = collectPrQualityFailures({
baseRef: pr.base.ref,
headRef: pr.head.ref,
headFromSameRepo: isSameGithubRepo(pr.head.repo, pr.base.repo),
allowedBases: ALLOWED_BASES,
body: pr.body,
behindMain,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-labeler.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: PR Labeler

# pull_request_target always loads this workflow from the repository DEFAULT
# branch (currently `main`), not from `dev`. Landing here on `dev` alone does
# branch (currently `main`). Landing here on a non-default branch alone does
# not change live labeler behavior until the change is also on that default
# branch — same promotion model as enforce-issue-quality.yml.
on:
Expand Down
Loading
Loading