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
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"projection": "deixic-code",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
"sourceSha": "38940ee14ede4a1d8f99729c6991b805720314b2",
"sourceSha": "6619b92b1c7046d41afc4ec4e2378c94b3583913",
"destinationRepository": "dx-corp/code",
"priorProjectedBase": "66915470e59f656745b3a6cfd9ab30f6db2273e5",
"priorProjectedBase": "f62a292a8f62ab1ae09bebc7e3f6d5b9b5d6b855",
"definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7",
"toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13",
"contentDigest": "f199ddc735eb038778000c80291490a41682e110e834b8c98f37ca52cc0f3744",
"contentDigest": "07cdf7a268ab5ba55ce8db85b62d9507646cb8ffe4399a1b4d9099facdbda507",
"publicationEligible": true
}
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 26 additions & 14 deletions scripts/check-required-check-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,32 @@ export function assertGhOk(result, endpoint) {
}

export function fetchRequiredContexts(repo, branch) {
const endpoint = `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`;
const result = spawnSync(
"gh",
["api", endpoint, "--jq", "[.required_status_checks.checks[]?.context]"],
{ encoding: "utf8" },
);
const stdout = assertGhOk(result, endpoint);
try {
return JSON.parse(stdout);
} catch {
throw new CoverageBlindSpotError(
`gh api ${endpoint} returned invalid JSON`,
);
}
const branchPath = `repos/${repo}/branches/${encodeURIComponent(branch)}`;
const read = (endpoint, jq) => {
const result = spawnSync("gh", ["api", endpoint, "--jq", jq], {
encoding: "utf8",
});
const stdout = assertGhOk(result, endpoint);
try {
return JSON.parse(stdout);
} catch {
throw new CoverageBlindSpotError(
`gh api ${endpoint} returned invalid JSON`,
);
}
};
return [
...new Set([
...read(
`${branchPath}/protection`,
"[.required_status_checks.checks[]?.context]",
),
Comment thread
evalops-mirror[bot] marked this conversation as resolved.
...read(
`repos/${repo}/rules/branches/${encodeURIComponent(branch)}`,
'[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context]',
),
Comment thread
evalops-mirror[bot] marked this conversation as resolved.
]),
];
}

/**
Expand Down
87 changes: 59 additions & 28 deletions scripts/check-required-status-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,45 +68,76 @@ function parseArgs(argv) {
return args;
}

function fetchRequiredContexts(repo, branch, { strict = false } = {}) {
export function fetchRequiredContexts(
repo,
branch,
{ strict = false, run = spawnSync } = {},
) {
const endpoint = `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`;
const result = spawnSync(
"gh",
["api", endpoint, "--jq", "[.required_status_checks.checks[]?.context]"],
{ encoding: "utf8" },
);
if (result.error) {
throw new Error(`failed to run gh: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || "").trim() || "unknown error";
const rulesEndpoint = `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`;
const gh = (path, jq) => {
const result = run("gh", ["api", path, "--jq", jq], { encoding: "utf8" });
if (result.error) {
throw new Error(`failed to run gh: ${result.error.message}`);
}
return result;
};
const unreadable = (detail) => {
// A token without Administration read on branch protection gets 403/404.
// In strict mode (the repo whose invariant this is) that must fail the
// job: the whole point of this check is to prove required checks
// report, and a credential problem is exactly the kind of thing that
// must not let it go green silently. Non-strict callers (forks/other
// repos that can't hold the token or grant) still degrade to a
// warning + pass.
if (/HTTP (403|404)/.test(detail)) {
if (strict) {
throw new Error(
`gh api ${endpoint} failed: ${detail}. The token cannot read branch ` +
"protection (needs Administration read) on a repository where this " +
"invariant runs in --strict mode; it must fail closed rather than " +
"silently skip.",
);
}
console.warn(
`::warning::INVARIANT NOT VERIFIED: gh api ${endpoint} failed: ${detail}. ` +
"The token cannot read branch protection (needs Administration read), so " +
"required contexts could not be enumerated. This job passing does NOT mean " +
"required checks are reportable.",
if (strict) {
throw new Error(
`gh api ${endpoint} failed: ${detail}. The token cannot read branch ` +
"protection (needs Administration read) on a repository where this " +
"invariant runs in --strict mode; it must fail closed rather than " +
"silently skip.",
);
return null;
}
throw new Error(`gh api ${endpoint} failed: ${detail}`);
console.warn(
`::warning::INVARIANT NOT VERIFIED: gh api ${endpoint} failed: ${detail}. ` +
"The token cannot read branch protection (needs Administration read), so " +
"required contexts could not be enumerated. This job passing does NOT mean " +
"required checks are reportable.",
);
return null;
};

const classic = gh(endpoint, "[.required_status_checks.checks[]?.context]");
let classicContexts = [];
let classicNotFound = false;
if (classic.status === 0) {
classicContexts = JSON.parse(classic.stdout);
} else {
const detail = (classic.stderr || "").trim() || "unknown error";
if (/HTTP 404/.test(detail)) {
classicNotFound = true;
} else if (/HTTP 403/.test(detail)) {
return unreadable(detail);
} else {
throw new Error(`gh api ${endpoint} failed: ${detail}`);
}
}

const rules = gh(
rulesEndpoint,
'[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context]',
);
if (rules.status !== 0) {
const detail = (rules.stderr || "").trim() || "unknown error";
throw new Error(`gh api ${rulesEndpoint} failed: ${detail}`);
}
const contexts = [
...new Set([...classicContexts, ...JSON.parse(rules.stdout)]),
];
if (classicNotFound && contexts.length === 0) {
return unreadable("HTTP 404 and no ruleset required_status_checks");
}
return JSON.parse(result.stdout);
return contexts;
}

function stripComment(line) {
Expand Down
47 changes: 46 additions & 1 deletion scripts/check-required-status-checks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";

import { evaluateRequiredStatusChecks } from "./check-required-status-checks.mjs";
import {
evaluateRequiredStatusChecks,
fetchRequiredContexts,
} from "./check-required-status-checks.mjs";

function withWorkflows(files, contexts) {
const root = mkdtempSync(join(tmpdir(), "required-checks-"));
Expand Down Expand Up @@ -117,3 +120,45 @@ jobs:
assert.equal(failures.length, 1);
assert.match(failures[0], /never runs on pull_request/u);
});

test("fetchRequiredContexts unions classic protection and ruleset contexts", () => {
const ok = (stdout) => ({ status: 0, stdout, stderr: "" });
const run = (_cmd, args) =>
args[1].includes("/rules/")
? ok('["validate","platform-ci"]')
: ok('["validate","legacy"]');
assert.deepEqual(
fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }),
["validate", "legacy", "platform-ci"],
);
});

test("fetchRequiredContexts treats classic 404 as empty and reads the ruleset", () => {
const run = (_cmd, args) =>
args[1].includes("/rules/")
? { status: 0, stdout: '["validate","semgrep"]', stderr: "" }
: { status: 1, stdout: "", stderr: "gh: Not Found (HTTP 404)" };
assert.deepEqual(
fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }),
["validate", "semgrep"],
);
});

test("fetchRequiredContexts strict still fails on classic 404 with no ruleset checks", () => {
const run = (_cmd, args) =>
args[1].includes("/rules/")
? { status: 0, stdout: "[]", stderr: "" }
: { status: 1, stdout: "", stderr: "gh: Not Found (HTTP 404)" };
assert.throws(
() => fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }),
/fail closed/u,
);
});

test("fetchRequiredContexts strict still fails on classic 403", () => {
const run = () => ({ status: 1, stdout: "", stderr: "gh: Forbidden (HTTP 403)" });
assert.throws(
() => fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }),
/fail closed/u,
);
});
29 changes: 21 additions & 8 deletions scripts/pr-ready-to-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -436,17 +436,30 @@ export function fetchRequiredStatusChecks(repo, branch, queryGh = ghJson) {
if (!branch) {
return null;
}
const encoded = encodeURIComponent(branch);
try {
const data = queryGh([
"api",
`repos/${repo}/branches/${encodeURIComponent(branch)}/protection/required_status_checks`,
]);
return Array.from(
new Set([
let classic = [];
try {
const data = queryGh([
"api",
`repos/${repo}/branches/${encoded}/protection/required_status_checks`,
]);
classic = [
...(data.contexts ?? []),
...(data.checks ?? []).map((check) => check.context).filter(Boolean),
]),
);
];
} catch (error) {
if (!/HTTP 404/.test(`${error?.message ?? ""}${error?.stderr ?? ""}`)) {
throw error;
}
}
const rules = queryGh(["api", `repos/${repo}/rules/branches/${encoded}`]);
const ruleset = rules
.filter((rule) => rule.type === "required_status_checks")
.flatMap((rule) => rule.parameters?.required_status_checks ?? [])
.map((check) => check.context)
.filter(Boolean);
return Array.from(new Set([...classic, ...ruleset]));
} catch {
return null;
}
Expand Down
73 changes: 73 additions & 0 deletions scripts/pr-ready-to-merge.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { fetchRequiredStatusChecks } from "./pr-ready-to-merge.mjs";

const rules = [
{ type: "pull_request", parameters: {} },
{
type: "required_status_checks",
parameters: {
required_status_checks: [{ context: "validate" }, { context: "platform-ci" }],
},
},
];

function fakeGh({ classic, ruleset }) {
const calls = [];
const queryGh = (args) => {
calls.push(args[1]);
const result = args[1].includes("/protection/") ? classic : ruleset;
if (result instanceof Error) {
throw result;
}
return result;
};
return { calls, queryGh };
}

test("classic 404 is an empty classic set and the ruleset supplies the contexts", () => {
const { calls, queryGh } = fakeGh({
classic: new Error("gh: Required status checks not enabled (HTTP 404)"),
ruleset: rules,
});
assert.deepEqual(fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh), [
"validate",
"platform-ci",
]);
assert.deepEqual(calls, [
"repos/dx-corp/mono/branches/main/protection/required_status_checks",
"repos/dx-corp/mono/rules/branches/main",
]);
});

test("classic and ruleset contexts are unioned without duplicates", () => {
const { queryGh } = fakeGh({
classic: { contexts: ["validate"], checks: [{ context: "legacy" }] },
ruleset: rules,
});
assert.deepEqual(
fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh).sort(),
["legacy", "platform-ci", "validate"],
);
});

test("non-404 classic errors still return null", () => {
const { queryGh } = fakeGh({
classic: new Error("gh: Resource not accessible by integration (HTTP 403)"),
ruleset: rules,
});
assert.equal(fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh), null);
});

test("ruleset errors return null", () => {
const { queryGh } = fakeGh({
classic: { contexts: ["validate"], checks: [] },
ruleset: new Error("HTTP 500"),
});
assert.equal(fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh), null);
});

test("missing branch returns null", () => {
assert.equal(fetchRequiredStatusChecks("dx-corp/mono", "", () => []), null);
});
1 change: 1 addition & 0 deletions vendor/dex-loop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ thiserror = "2.0"
# still does no I/O of its own.
tokio = { version = "1.48", features = ["time"] }
tokio-util = "0.7"
url = { version = "2.5", features = ["serde"] }

[dev-dependencies]
proptest = "1.9"
Expand Down
Loading
Loading