diff --git a/.cursor/install.sh b/.cursor/install.sh index 6275c573..2a0433e5 100755 --- a/.cursor/install.sh +++ b/.cursor/install.sh @@ -4,12 +4,12 @@ # Installs the two runtime toolchains this repository pins at the repo level so # the dogfood test/lint inner loop is runnable end to end: # * Node — from .node-version, the runtime for the `node --test` suites and -# the tsc/biome action contracts. +# the biome action contract. # * .NET — from global.json, the SDK the dotnet-build / dotnet-format # fixtures compile against. # # Go and Python already ship in the base image. The per-tool lint binaries -# (lefthook, shellcheck, shfmt, actionlint, biome, typos, gitleaks, lychee, +# (shellcheck, shfmt, actionlint, biome, typos, gitleaks, lychee, # ruff, pyright, pwsh) are intentionally NOT installed here: each composite # action installs its own pinned, checksum-verified version at run time, so # pre-seeding them in the environment would duplicate and drift from those diff --git a/.github/actions/lefthook-validate/action.yml b/.github/actions/lefthook-validate/action.yml deleted file mode 100644 index a51059df..00000000 --- a/.github/actions/lefthook-validate/action.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: lefthook-validate -description: Validate the caller's composed Lefthook configuration. - -inputs: - config-file: - description: >- - Path to the caller's main Lefthook config. Empty (default) uses - Lefthook's native config discovery. Extends, remotes, and the matching - lefthook-local config remain part of the composed validation input. - default: '' - version: - description: >- - Exact Lefthook version to install. Keep this aligned with the version - used by the standards-managed consumer toolchain. - default: 2.1.12 - sha256: - description: >- - SHA-256 of the Linux_x86_64 release binary for `version`. Change - together with `version`. - default: 22ff1ad48d1a0f4dca8d6b7e920056c6a9015f9204e5b693858ed9c2db759a16 - -runs: - using: composite - steps: - # Persist the verified binary across jobs so a warm version+sha256 pin - # never touches the network — the shellcheck action's #156 pattern, - # extended here as the primary defence against release-asset outages - # like 2026-08-12 (#444). install-release.sh re-verifies the pinned - # SHA-256 on every restore and re-downloads on mismatch, so the cache - # key is never trusted by itself. - - name: Cache Lefthook release asset - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ runner.temp }}/ci-workflows-release-cache/lefthook - key: lefthook-${{ runner.os }}-${{ runner.arch }}-${{ inputs.version }}-${{ inputs.sha256 }} - - - name: Install Lefthook - shell: bash - env: - URL: https://github.com/evilmartians/lefthook/releases/download/v${{ inputs.version }}/lefthook_${{ inputs.version }}_Linux_x86_64 - SHA256: ${{ inputs.sha256 }} - BIN: lefthook - ASSET_CACHE_DIR: ${{ runner.temp }}/ci-workflows-release-cache/lefthook - run: bash "$GITHUB_ACTION_PATH/../_shared/install-release.sh" - - - name: Validate composed Lefthook config - shell: bash - env: - CONFIG_FILE: ${{ inputs.config-file }} - run: | - set -euo pipefail - if [[ -n "${CONFIG_FILE// }" ]]; then - if [[ ! -f "$CONFIG_FILE" ]]; then - echo "::error::lefthook-validate: config file not found: $CONFIG_FILE" - exit 2 - fi - export LEFTHOOK_CONFIG="$CONFIG_FILE" - else - unset LEFTHOOK_CONFIG - fi - lefthook validate diff --git a/.github/actions/lefthook-validate/fixtures/bad/fragments/invalid.yml b/.github/actions/lefthook-validate/fixtures/bad/fragments/invalid.yml deleted file mode 100644 index 927c9ec5..00000000 --- a/.github/actions/lefthook-validate/fixtures/bad/fragments/invalid.yml +++ /dev/null @@ -1,5 +0,0 @@ -pre-commit: - commands: - inherited: - run: echo inherited - not-a-lefthook-option: true diff --git a/.github/actions/lefthook-validate/fixtures/bad/lefthook.yml b/.github/actions/lefthook-validate/fixtures/bad/lefthook.yml deleted file mode 100644 index 78d5bf23..00000000 --- a/.github/actions/lefthook-validate/fixtures/bad/lefthook.yml +++ /dev/null @@ -1,7 +0,0 @@ -extends: - - .github/actions/lefthook-validate/fixtures/bad/fragments/invalid.yml - -pre-commit: - commands: - fixture-local: - run: echo fixture-local diff --git a/.github/actions/lefthook-validate/fixtures/good/fragments/base.yml b/.github/actions/lefthook-validate/fixtures/good/fragments/base.yml deleted file mode 100644 index 9a121a3b..00000000 --- a/.github/actions/lefthook-validate/fixtures/good/fragments/base.yml +++ /dev/null @@ -1,5 +0,0 @@ -pre-commit: - commands: - inherited: - glob: "*.md" - run: echo inherited diff --git a/.github/actions/lefthook-validate/fixtures/good/lefthook.yml b/.github/actions/lefthook-validate/fixtures/good/lefthook.yml deleted file mode 100644 index 4c940f74..00000000 --- a/.github/actions/lefthook-validate/fixtures/good/lefthook.yml +++ /dev/null @@ -1,7 +0,0 @@ -extends: - - .github/actions/lefthook-validate/fixtures/good/fragments/base.yml - -pre-commit: - commands: - fixture-local: - run: echo fixture-local diff --git a/.github/actions/tsc/action.yml b/.github/actions/tsc/action.yml deleted file mode 100644 index 29d749f4..00000000 --- a/.github/actions/tsc/action.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: tsc -description: Type-check TypeScript with the compiler (tsc --noEmit, strict) against a caller-supplied project. - -inputs: - project: - description: >- - Path to the tsconfig.json (or its directory) to type-check, passed as - tsc's --project. tsc cannot mix --project with file arguments (TS5042), - so scope via the project's own `include` or `files`. - default: tsconfig.json - version: - description: Exact TypeScript version to run. - default: 7.0.2 - node-version: - description: Node.js version to set up. - default: 24.20.0 - -runs: - using: composite - steps: - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: ${{ inputs.node-version }} - - - name: Type-check - shell: bash - env: - PROJECT: ${{ inputs.project }} - VERSION: ${{ inputs.version }} - # --noEmit type-checks without writing JS; it lives on the CLI (not the - # shared base) so the base stays usable for builds that emit. The `tsc` - # binary ships in the `typescript` package, hence `npx --package`. tsc has - # no native GitHub-annotation format, so findings are plain text (as with - # pyright); the non-zero exit gates the build. - run: | - set -euo pipefail - - if [[ ! -e "$PROJECT" ]]; then - echo "::error::tsc: project path not found: $PROJECT" - exit 2 - fi - npx --yes --package "typescript@$VERSION" tsc --noEmit --project "$PROJECT" diff --git a/.github/scripts/check-run-reconcile.cjs b/.github/scripts/check-run-reconcile.cjs deleted file mode 100755 index 0b762115..00000000 --- a/.github/scripts/check-run-reconcile.cjs +++ /dev/null @@ -1,859 +0,0 @@ -#!/usr/bin/env node -// Reconcile required-check contexts against workflow jobs and commit check-runs. -// -// GitHub's merge gate resolves required contexts from the commit check-run list. -// Workflow runs / `gh pr checks` can still report green when a required context -// never attached (ci-workflows#399). This probe correlates the two surfaces and -// fails loudly on divergence: a completed workflow job on the head SHA with no -// matching check-run on that SHA. -// -// Pure reconcile logic is network-free and unit-tested. The CLI shells out to -// `gh api` to fetch live surfaces for operator use: -// -// node .github/scripts/check-run-reconcile.cjs \ -// --repo melodic-software/claude-code-plugins --pr 2123 --from-rulesets -// -"use strict"; - -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); - -const TERMINAL_JOB_STATUSES = new Set(["completed"]); -const PENDING_STATUSES = new Set([ - "queued", - "in_progress", - "waiting", - "requested", - "pending", -]); -// Merge-gate blocking conclusions. Completed checks with these conclusions -// must not report aligned/ok even when job and check-run agree. -const FAILING_CHECK_CONCLUSIONS = new Set([ - "failure", - "cancelled", - "timed_out", -]); -// Verdicts that make merge-readiness untrustworthy. Kept module-level (not -// exported: the export list is the public contract) so the reconcile filter -// does not re-allocate the set on every call. -const PROBLEM_VERDICTS = new Set([ - "divergence", - "missing", - "mismatch", - "pending", - "failed", -]); - -class UsageError extends Error { - constructor(message) { - super(message); - this.name = "UsageError"; - } -} - -/** - * Prefer the newest record when several share a name (reopen / rerun churn). - */ -function pickLatestByName( - items, - nameKey = "name", - timeKeys = ["completed_at", "started_at"], -) { - if (!Array.isArray(items)) { - throw new UsageError("expected an array of named records"); - } - const latest = new Map(); - for (const item of items) { - if (item === null || typeof item !== "object") { - continue; - } - const name = item[nameKey]; - if (typeof name !== "string" || name.length === 0) { - continue; - } - const stamp = timeKeys - .map((key) => item[key]) - .find((value) => typeof value === "string" && value.length > 0); - const rank = stamp ? Date.parse(stamp) : Number.NaN; - const prev = latest.get(name); - if (!prev) { - latest.set(name, { item, rank: Number.isFinite(rank) ? rank : 0 }); - continue; - } - const nextRank = Number.isFinite(rank) ? rank : 0; - if (nextRank >= prev.rank) { - latest.set(name, { item, rank: nextRank }); - } - } - return new Map(latest.entries().map(([name, entry]) => [name, entry.item])); -} - -/** - * GitHub ruleset ref_name patterns use fnmatch with FNM_PATHNAME (* does not - * cross `/`). Also accepts ~ALL and ~DEFAULT_BRANCH. - */ -function matchRefPattern(pattern, fullRef, defaultBranch) { - if (typeof pattern !== "string" || pattern.length === 0) { - return false; - } - if (pattern === "~ALL") { - return true; - } - if (pattern === "~DEFAULT_BRANCH") { - if (typeof defaultBranch !== "string" || defaultBranch.length === 0) { - return false; - } - const defaultRef = defaultBranch.startsWith("refs/") - ? defaultBranch - : `refs/heads/${defaultBranch}`; - return fullRef === defaultRef; - } - // Escape regex metacharacters, then map fnmatch * / ? (pathname: * ≠ `/`). - let regexSource = ""; - for (let i = 0; i < pattern.length; i += 1) { - const ch = pattern[i]; - if (ch === "*") { - if (pattern[i + 1] === "*") { - // `**` matches across slashes (GitHub docs allow qa/**/*). - regexSource += ".*"; - i += 1; - } else { - regexSource += "[^/]*"; - } - } else if (ch === "?") { - regexSource += "[^/]"; - } else if ("\\^$.|()+[]{}/".includes(ch)) { - regexSource += `\\${ch}`; - } else { - regexSource += ch; - } - } - return new RegExp(`^${regexSource}$`, "u").test(fullRef); -} - -/** - * Whether a ruleset's conditions.ref_name applies to the PR target ref. - * When targetRef is omitted, every ruleset is kept (fixture / sha-only paths). - */ -function rulesetAppliesToRef( - ruleset, - { targetRef = null, defaultBranch = null } = {}, -) { - if (targetRef === null || targetRef === undefined || targetRef === "") { - return true; - } - if (ruleset.target && ruleset.target !== "branch") { - return false; - } - const fullRef = targetRef.startsWith("refs/") - ? targetRef - : `refs/heads/${targetRef}`; - const refName = ruleset.conditions?.ref_name; - if (!refName || typeof refName !== "object") { - return true; - } - const include = Array.isArray(refName.include) ? refName.include : []; - const exclude = Array.isArray(refName.exclude) ? refName.exclude : []; - const included = - include.length === 0 || - include.some((pattern) => matchRefPattern(pattern, fullRef, defaultBranch)); - if (!included) { - return false; - } - return !exclude.some((pattern) => - matchRefPattern(pattern, fullRef, defaultBranch), - ); -} - -/** - * Normalize a required-check entry to `{ context, integrationId }`. - * Strings (CLI `--context`) carry a null integrationId (any publisher). - */ -function normalizeRequiredCheck(entry) { - if (typeof entry === "string") { - const context = entry.trim(); - if (context === "") { - throw new UsageError("required contexts must be non-empty strings"); - } - return { context, integrationId: null }; - } - if (entry === null || typeof entry !== "object") { - throw new UsageError( - "required checks must be strings or {context, integrationId} objects", - ); - } - const context = typeof entry.context === "string" ? entry.context.trim() : ""; - if (context === "") { - throw new UsageError("required contexts must be non-empty strings"); - } - const rawId = entry.integrationId ?? entry.integration_id ?? null; - let integrationId = null; - if (rawId !== null && rawId !== undefined) { - if (typeof rawId !== "number" || !Number.isInteger(rawId)) { - throw new UsageError( - `integration_id for ${context} must be an integer or null`, - ); - } - integrationId = rawId; - } - return { context, integrationId }; -} - -/** - * Pull required status-check context/integration pairs from ruleset payloads. - * - * Skips `disabled` and `evaluate` (evaluate does not enforce). When - * `targetRef` is set, only rulesets whose ref_name conditions match that - * PR base ref contribute. - * - * @returns {Array<{ context: string, integrationId: number | null }>} - */ -function extractRequiredContextsFromRulesets( - rulesets, - { targetRef = null, defaultBranch = null } = {}, -) { - if (!Array.isArray(rulesets)) { - throw new UsageError("expected an array of ruleset objects"); - } - const contexts = []; - // Dedup key includes integration so the same name from two apps stays distinct. - const seen = new Set(); - for (const ruleset of rulesets) { - if (ruleset === null || typeof ruleset !== "object") { - continue; - } - if ( - ruleset.enforcement === "disabled" || - ruleset.enforcement === "evaluate" - ) { - continue; - } - if (!rulesetAppliesToRef(ruleset, { targetRef, defaultBranch })) { - continue; - } - const rules = Array.isArray(ruleset.rules) ? ruleset.rules : []; - for (const rule of rules) { - if (rule?.type !== "required_status_checks") { - continue; - } - const checks = rule.parameters?.required_status_checks; - if (!Array.isArray(checks)) { - continue; - } - for (const check of checks) { - const context = - typeof check?.context === "string" ? check.context.trim() : ""; - if (context === "") { - continue; - } - const rawId = check.integration_id; - const integrationId = - typeof rawId === "number" && Number.isInteger(rawId) ? rawId : null; - const key = `${context}\0${integrationId ?? ""}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - contexts.push({ context, integrationId }); - } - } - } - return contexts; -} - -/** - * Pick the newest check-run matching name and optional integration_id - * (compared to check-run `app.id`). - */ -function pickLatestCheckRun(checkRuns, context, integrationId) { - const candidates = []; - for (const run of checkRuns) { - if (run === null || typeof run !== "object") { - continue; - } - if (run.name !== context) { - continue; - } - if (integrationId !== null && integrationId !== undefined) { - const appId = run.app?.id; - if (appId !== integrationId) { - continue; - } - } - candidates.push(run); - } - return pickLatestByName(candidates).get(context) ?? null; -} - -/** - * Classify one required context against the latest job and check-run of that name. - * - * @returns {{ - * context: string, - * integrationId: number | null, - * verdict: - * | "aligned" - * | "divergence" - * | "missing" - * | "pending" - * | "mismatch" - * | "check_only" - * | "failed", - * job: object | null, - * checkRun: object | null, - * detail: string, - * }} - */ -function classifyContext(context, job, checkRun, integrationId = null) { - const jobStatus = typeof job?.status === "string" ? job.status : null; - const jobConclusion = - typeof job?.conclusion === "string" ? job.conclusion : null; - const checkStatus = - typeof checkRun?.status === "string" ? checkRun.status : null; - const checkConclusion = - typeof checkRun?.conclusion === "string" ? checkRun.conclusion : null; - - const jobPending = - job !== null && - (PENDING_STATUSES.has(jobStatus) || - (TERMINAL_JOB_STATUSES.has(jobStatus) && jobConclusion === null)); - // Anything other than status=completed is still in flight for check-runs - // (PENDING_STATUSES is a documented subset; unknown statuses stay pending). - const checkPending = checkRun !== null && checkStatus !== "completed"; - - if (jobPending || checkPending) { - return { - context, - integrationId, - verdict: "pending", - job, - checkRun, - detail: "job or check-run still in flight", - }; - } - - if (job !== null && checkRun === null) { - return { - context, - integrationId, - verdict: "divergence", - job, - checkRun, - detail: - `workflow job completed (${jobConclusion ?? jobStatus}) on the head SHA ` + - "but no matching check-run is attached to that commit (ci-workflows#399)", - }; - } - - if (job === null && checkRun === null) { - return { - context, - integrationId, - verdict: "missing", - job, - checkRun, - detail: - "required context has neither a workflow job nor a commit check-run on this SHA", - }; - } - - if (job === null && checkRun !== null) { - if (FAILING_CHECK_CONCLUSIONS.has(checkConclusion)) { - return { - context, - integrationId, - verdict: "failed", - job, - checkRun, - detail: `required check-run concluded ${checkConclusion}`, - }; - } - return { - context, - integrationId, - verdict: "check_only", - job, - checkRun, - detail: `check-run present (${checkConclusion ?? checkStatus}); no matching workflow job observed`, - }; - } - - if (jobConclusion !== checkConclusion) { - return { - context, - integrationId, - verdict: "mismatch", - job, - checkRun, - detail: `workflow job conclusion=${jobConclusion} vs check-run conclusion=${checkConclusion}`, - }; - } - - if (FAILING_CHECK_CONCLUSIONS.has(checkConclusion)) { - return { - context, - integrationId, - verdict: "failed", - job, - checkRun, - detail: `required check concluded ${checkConclusion}`, - }; - } - - return { - context, - integrationId, - verdict: "aligned", - job, - checkRun, - detail: `both surfaces report ${checkConclusion ?? checkStatus}`, - }; -} - -/** - * Reconcile every required context. - * - * A non-empty `problems` list means merge-readiness cannot be trusted from - * "no failing checks" alone: either a required context is absent, diverged - * (job without check-run), mismatched, still pending, or completed failing. - * - * `requiredContexts` accepts plain strings or `{ context, integrationId }` - * objects (ruleset extraction preserves integration_id). - */ -function reconcileRequiredChecks({ - requiredContexts, - checkRuns, - workflowJobs, -}) { - if (!Array.isArray(requiredContexts) || requiredContexts.length === 0) { - throw new UsageError("at least one required context is required"); - } - const requiredChecks = requiredContexts.map(normalizeRequiredCheck); - - if (!Array.isArray(checkRuns)) { - throw new UsageError("checkRuns must be an array"); - } - if (!Array.isArray(workflowJobs)) { - throw new UsageError("workflowJobs must be an array"); - } - - const jobsByName = pickLatestByName(workflowJobs); - const results = requiredChecks.map(({ context, integrationId }) => { - const checkRun = pickLatestCheckRun(checkRuns, context, integrationId); - return classifyContext( - context, - jobsByName.get(context) ?? null, - checkRun, - integrationId, - ); - }); - - const problems = results.filter((row) => PROBLEM_VERDICTS.has(row.verdict)); - const divergences = results.filter((row) => row.verdict === "divergence"); - - return { - ok: problems.length === 0, - results, - problems, - divergences, - // Required contexts with no attached check-run — the merge-gate signal. - absentCheckRuns: results.filter((row) => row.checkRun === null), - }; -} - -function formatReconcileReport(report, { sha = null, repo = null } = {}) { - const lines = []; - const where = [repo, sha].filter((part) => typeof part === "string" && part); - lines.push( - `check-run reconcile${where.length ? ` (${where.join(" @ ")})` : ""}`, - ); - for (const row of report.results) { - const jobBit = row.job - ? `job=${row.job.conclusion ?? row.job.status}` - : "job=ABSENT"; - const checkBit = row.checkRun - ? `check=${row.checkRun.conclusion ?? row.checkRun.status}` - : "check=ABSENT"; - lines.push( - `- ${row.context}: ${row.verdict} (${jobBit}; ${checkBit}) — ${row.detail}`, - ); - } - if (report.divergences.length > 0) { - lines.push( - `::error::${report.divergences.length} required context(s) ran as workflow jobs but did not attach as commit check-runs (ci-workflows#399).`, - ); - } else if (report.absentCheckRuns.length > 0) { - lines.push( - `::error::${report.absentCheckRuns.length} required context(s) have no check-run on this SHA; merge will stay BLOCKED even if other surfaces look green.`, - ); - } else if (!report.ok) { - lines.push( - `::error::required-check reconcile found ${report.problems.length} problem(s).`, - ); - } else { - lines.push( - "ok: every required context is present on the commit check-run list.", - ); - } - return `${lines.join("\n")}\n`; -} - -function parseArgs(argv) { - const options = { - repo: null, - sha: null, - pr: null, - contexts: [], - fromRulesets: false, - json: false, - checkRunsJson: null, - jobsJson: null, - rulesetsJson: null, - targetRef: null, - defaultBranch: null, - }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - const next = () => { - const value = argv[i + 1]; - if (value === undefined) { - throw new UsageError(`missing value for ${arg}`); - } - i += 1; - return value; - }; - switch (arg) { - case "--repo": - options.repo = next(); - break; - case "--sha": - options.sha = next(); - break; - case "--pr": - options.pr = next(); - break; - case "--context": - options.contexts.push(next()); - break; - case "--from-rulesets": - options.fromRulesets = true; - break; - case "--json": - options.json = true; - break; - case "--check-runs-json": - options.checkRunsJson = next(); - break; - case "--jobs-json": - options.jobsJson = next(); - break; - case "--rulesets-json": - options.rulesetsJson = next(); - break; - case "--target-ref": - options.targetRef = next(); - break; - case "--default-branch": - options.defaultBranch = next(); - break; - case "--help": - case "-h": - options.help = true; - break; - default: - throw new UsageError(`unknown argument: ${arg}`); - } - } - return options; -} - -function usage() { - return `Usage: - check-run-reconcile.cjs --repo OWNER/NAME --sha SHA --context NAME [--context NAME...] - check-run-reconcile.cjs --repo OWNER/NAME --pr N --from-rulesets - check-run-reconcile.cjs --check-runs-json FILE --jobs-json FILE --context NAME... - check-run-reconcile.cjs --check-runs-json FILE --jobs-json FILE --rulesets-json FILE \\ - [--target-ref REF] [--default-branch NAME] - -Compares workflow jobs on a head SHA to commit check-runs for each required -context. Exit 0 when every required context has an attached non-failing -check-run and no divergence/mismatch/pending/failed rows remain; exit 1 on -reconcile problems; exit 2 on usage or fetch errors. - ---rulesets-json / --from-rulesets derive contexts from rulesets (skipping -disabled and evaluate). Explicit --context values win unless --from-rulesets -is also set (fixture --rulesets-json follows the same rule: only overrides -when no --context was given). Pass --target-ref (PR base) to filter rulesets -by conditions.ref_name; --default-branch resolves ~DEFAULT_BRANCH. -`; -} - -function readJsonFile(path) { - try { - return JSON.parse(fs.readFileSync(path, "utf8")); - } catch (error) { - throw new UsageError( - `could not read JSON from ${path}: ${error instanceof Error ? error.message : error}`, - ); - } -} - -function ghApiJson(path, { paginate = false } = {}) { - const args = ["api", path, "--method", "GET"]; - if (paginate) { - args.push("--paginate"); - } - const result = spawnSync("gh", args, { - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - }); - if (result.error) { - throw new UsageError(`failed to spawn gh: ${result.error.message}`); - } - if (result.status !== 0) { - const detail = (result.stderr || result.stdout || "").trim(); - throw new UsageError( - `gh api ${path} failed (exit ${result.status}): ${detail}`, - ); - } - try { - return JSON.parse(result.stdout); - } catch (error) { - throw new UsageError( - `gh api ${path} returned non-JSON: ${error instanceof Error ? error.message : error}`, - ); - } -} - -function collectCheckRuns(repo, sha) { - const runs = []; - let page = 1; - for (;;) { - const payload = ghApiJson( - `repos/${repo}/commits/${sha}/check-runs?per_page=100&page=${page}`, - ); - const batch = Array.isArray(payload.check_runs) ? payload.check_runs : []; - runs.push(...batch); - if (batch.length < 100) { - break; - } - page += 1; - if (page > 50) { - throw new UsageError("check-run pagination exceeded safety cap"); - } - } - return runs; -} - -function collectWorkflowJobs(repo, sha) { - const jobs = []; - let page = 1; - for (;;) { - const payload = ghApiJson( - `repos/${repo}/actions/runs?head_sha=${encodeURIComponent(sha)}&per_page=100&page=${page}`, - ); - const runs = Array.isArray(payload.workflow_runs) - ? payload.workflow_runs - : []; - for (const run of runs) { - let jobPage = 1; - for (;;) { - const jobPayload = ghApiJson( - `repos/${repo}/actions/runs/${run.id}/jobs?per_page=100&page=${jobPage}`, - ); - const batch = Array.isArray(jobPayload.jobs) ? jobPayload.jobs : []; - for (const job of batch) { - jobs.push({ - ...job, - workflow_name: run.name, - workflow_id: run.id, - workflow_path: run.path, - head_sha: run.head_sha, - }); - } - if (batch.length < 100) { - break; - } - jobPage += 1; - if (jobPage > 20) { - throw new UsageError("job pagination exceeded safety cap"); - } - } - } - if (runs.length < 100) { - break; - } - page += 1; - if (page > 20) { - throw new UsageError("workflow-run pagination exceeded safety cap"); - } - } - return jobs; -} - -function collectRulesetDetails(repo) { - const listing = ghApiJson(`repos/${repo}/rulesets`); - const rulesets = Array.isArray(listing) ? listing : []; - return rulesets.map((entry) => - ghApiJson(`repos/${repo}/rulesets/${entry.id}`), - ); -} - -function resolvePull(repo, pr) { - const pull = ghApiJson(`repos/${repo}/pulls/${pr}`); - const sha = pull?.head?.sha; - if (typeof sha !== "string" || sha.length === 0) { - throw new UsageError(`could not resolve head SHA for PR #${pr}`); - } - const baseRef = pull?.base?.ref; - if (typeof baseRef !== "string" || baseRef.length === 0) { - throw new UsageError(`could not resolve base ref for PR #${pr}`); - } - return { sha, baseRef }; -} - -function resolveDefaultBranch(repo) { - const info = ghApiJson(`repos/${repo}`); - const branch = info?.default_branch; - if (typeof branch !== "string" || branch.length === 0) { - throw new UsageError(`could not resolve default branch for ${repo}`); - } - return branch; -} - -function contextNames(requiredChecks) { - return requiredChecks.map((entry) => - typeof entry === "string" ? entry : entry.context, - ); -} - -function main(argv = process.argv.slice(2)) { - const options = parseArgs(argv); - if (options.help) { - process.stdout.write(usage()); - return 0; - } - - let checkRuns; - let workflowJobs; - let requiredContexts = options.contexts.map((c) => c.trim()).filter(Boolean); - let sha = options.sha; - const repo = options.repo; - let targetRef = options.targetRef; - let defaultBranch = options.defaultBranch; - - if (options.checkRunsJson || options.jobsJson) { - if (!options.checkRunsJson || !options.jobsJson) { - throw new UsageError( - "--check-runs-json and --jobs-json must be provided together", - ); - } - const checkPayload = readJsonFile(options.checkRunsJson); - const jobsPayload = readJsonFile(options.jobsJson); - checkRuns = Array.isArray(checkPayload) - ? checkPayload - : checkPayload.check_runs; - workflowJobs = Array.isArray(jobsPayload) ? jobsPayload : jobsPayload.jobs; - if (!Array.isArray(checkRuns) || !Array.isArray(workflowJobs)) { - throw new UsageError( - "fixture JSON must be an array or an object with check_runs/jobs arrays", - ); - } - // Match live mode: --rulesets-json only overrides when no explicit - // --context was given (or the caller also set --from-rulesets). - if ( - options.rulesetsJson && - (options.fromRulesets || requiredContexts.length === 0) - ) { - const rulesets = readJsonFile(options.rulesetsJson); - requiredContexts = extractRequiredContextsFromRulesets( - Array.isArray(rulesets) ? rulesets : [rulesets], - { targetRef, defaultBranch }, - ); - } - } else { - if (!repo || typeof repo !== "string" || !repo.includes("/")) { - throw new UsageError("--repo OWNER/NAME is required for live fetches"); - } - if (options.pr && !sha) { - const pull = resolvePull(repo, options.pr); - sha = pull.sha; - if (!targetRef) { - targetRef = pull.baseRef; - } - } - if (!sha) { - throw new UsageError("--sha or --pr is required"); - } - if (options.fromRulesets || requiredContexts.length === 0) { - if (!defaultBranch) { - defaultBranch = resolveDefaultBranch(repo); - } - requiredContexts = extractRequiredContextsFromRulesets( - collectRulesetDetails(repo), - { targetRef, defaultBranch }, - ); - } - checkRuns = collectCheckRuns(repo, sha); - workflowJobs = collectWorkflowJobs(repo, sha); - } - - if (requiredContexts.length === 0) { - throw new UsageError( - "no required contexts provided; pass --context or --from-rulesets", - ); - } - - const report = reconcileRequiredChecks({ - requiredContexts, - checkRuns, - workflowJobs, - }); - - if (options.json) { - process.stdout.write( - `${JSON.stringify( - { - repo, - sha, - requiredContexts: contextNames(requiredContexts), - requiredChecks: requiredContexts.map(normalizeRequiredCheck), - ...report, - }, - null, - 2, - )}\n`, - ); - } else { - process.stdout.write(formatReconcileReport(report, { repo, sha })); - } - - return report.ok ? 0 : 1; -} - -module.exports = Object.freeze({ - UsageError, - FAILING_CHECK_CONCLUSIONS, - pickLatestByName, - matchRefPattern, - rulesetAppliesToRef, - normalizeRequiredCheck, - extractRequiredContextsFromRulesets, - pickLatestCheckRun, - classifyContext, - reconcileRequiredChecks, - formatReconcileReport, - parseArgs, - main, -}); - -if (require.main === module) { - try { - process.exitCode = main(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`::error::${message}\n`); - // Usage/fetch errors and unexpected failures both exit 2 (see usage()). - process.exitCode = 2; - } -} diff --git a/.github/scripts/check-run-reconcile.test.cjs b/.github/scripts/check-run-reconcile.test.cjs deleted file mode 100644 index 47988631..00000000 --- a/.github/scripts/check-run-reconcile.test.cjs +++ /dev/null @@ -1,555 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const { - UsageError, - pickLatestByName, - extractRequiredContextsFromRulesets, - matchRefPattern, - classifyContext, - reconcileRequiredChecks, - formatReconcileReport, - main, -} = require("./check-run-reconcile.cjs"); - -const REQUIRED = Object.freeze([ - "pr-title / pr-title", - "do-not-merge / do-not-merge", - "ci-status", -]); - -function job( - name, - { - conclusion = "success", - status = "completed", - at = "2026-08-10T02:00:00Z", - id = 1, - } = {}, -) { - return { - id, - name, - status, - conclusion, - started_at: at, - completed_at: at, - }; -} - -function check( - name, - { - conclusion = "success", - status = "completed", - at = "2026-08-10T02:00:00Z", - id = 1, - appId = null, - } = {}, -) { - const run = { - id, - name, - status, - conclusion, - started_at: at, - completed_at: at, - }; - if (appId !== null) { - run.app = { id: appId }; - } - return run; -} - -test("pickLatestByName keeps the newest same-named record", () => { - const map = pickLatestByName([ - check("ci-status", { id: 1, at: "2026-08-10T01:00:00Z" }), - check("ci-status", { id: 2, at: "2026-08-10T03:00:00Z" }), - check("pr-title / pr-title", { id: 3, at: "2026-08-10T02:00:00Z" }), - ]); - assert.equal(map.get("ci-status").id, 2); - assert.equal(map.get("pr-title / pr-title").id, 3); -}); - -test("extractRequiredContextsFromRulesets reads active required_status_checks", () => { - const contexts = extractRequiredContextsFromRulesets([ - { - name: "ci-gate", - enforcement: "active", - target: "branch", - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [ - { context: "pr-title / pr-title" }, - { context: "do-not-merge / do-not-merge" }, - { context: "ci-status" }, - { context: "ci-status" }, - ], - }, - }, - ], - }, - { - name: "disabled-gate", - enforcement: "disabled", - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [{ context: "should-ignore" }], - }, - }, - ], - }, - { - name: "evaluate-gate", - enforcement: "evaluate", - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [{ context: "evaluate-only" }], - }, - }, - ], - }, - ]); - assert.deepEqual( - contexts.map((entry) => entry.context), - [...REQUIRED], - ); - assert.equal( - contexts.every((entry) => entry.integrationId === null), - true, - ); -}); - -test("extractRequiredContextsFromRulesets preserves integration_id", () => { - const contexts = extractRequiredContextsFromRulesets([ - { - enforcement: "active", - target: "branch", - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [ - { context: "ci-status", integration_id: 15368 }, - { context: "external / scan", integration_id: 42 }, - ], - }, - }, - ], - }, - ]); - assert.deepEqual(contexts, [ - { context: "ci-status", integrationId: 15368 }, - { context: "external / scan", integrationId: 42 }, - ]); -}); - -test("extractRequiredContextsFromRulesets filters by target ref", () => { - const rulesets = [ - { - name: "main-gate", - enforcement: "active", - target: "branch", - conditions: { - ref_name: { - include: ["refs/heads/main", "~DEFAULT_BRANCH"], - exclude: [], - }, - }, - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [{ context: "ci-status" }], - }, - }, - ], - }, - { - name: "release-gate", - enforcement: "active", - target: "branch", - conditions: { - ref_name: { - include: ["refs/heads/release/*"], - exclude: [], - }, - }, - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [{ context: "release-check" }], - }, - }, - ], - }, - ]; - assert.deepEqual( - extractRequiredContextsFromRulesets(rulesets, { - targetRef: "main", - defaultBranch: "main", - }).map((entry) => entry.context), - ["ci-status"], - ); - assert.deepEqual( - extractRequiredContextsFromRulesets(rulesets, { - targetRef: "release/1.0", - defaultBranch: "main", - }).map((entry) => entry.context), - ["release-check"], - ); -}); - -test("matchRefPattern handles ~ALL, globs, and excludes pathname *", () => { - assert.equal(matchRefPattern("~ALL", "refs/heads/main", "main"), true); - assert.equal( - matchRefPattern("~DEFAULT_BRANCH", "refs/heads/main", "main"), - true, - ); - assert.equal( - matchRefPattern("refs/heads/release/*", "refs/heads/release/1.0", "main"), - true, - ); - assert.equal( - matchRefPattern("refs/heads/release/*", "refs/heads/release/a/b", "main"), - false, - ); -}); - -test("aligned: every required context has matching job and check-run", () => { - const report = reconcileRequiredChecks({ - requiredContexts: REQUIRED, - workflowJobs: REQUIRED.map((name, index) => job(name, { id: index + 1 })), - checkRuns: REQUIRED.map((name, index) => check(name, { id: index + 1 })), - }); - assert.equal(report.ok, true); - assert.equal(report.problems.length, 0); - assert.equal(report.divergences.length, 0); - assert.deepEqual( - report.results.map((row) => row.verdict), - ["aligned", "aligned", "aligned"], - ); -}); - -test("#399 divergence: workflow job succeeded but check-run never attached", () => { - // Head 2895890c shape from the issue: ci-status + pr-title attached, - // do-not-merge ran as a job but is ABSENT from commit check-runs. - const report = reconcileRequiredChecks({ - requiredContexts: REQUIRED, - workflowJobs: [ - job("pr-title / pr-title", { id: 10 }), - job("do-not-merge / do-not-merge", { id: 11 }), - job("ci-status", { id: 12 }), - ], - checkRuns: [ - check("pr-title / pr-title", { id: 20 }), - check("ci-status", { id: 21 }), - // do-not-merge deliberately absent - ], - }); - assert.equal(report.ok, false); - assert.equal(report.divergences.length, 1); - assert.equal(report.divergences[0].context, "do-not-merge / do-not-merge"); - assert.equal(report.divergences[0].verdict, "divergence"); - assert.match(report.divergences[0].detail, /ci-workflows#399/); - assert.equal(report.absentCheckRuns.length, 1); - assert.equal( - report.absentCheckRuns[0].context, - "do-not-merge / do-not-merge", - ); -}); - -test("missing: required context has neither job nor check-run", () => { - const report = reconcileRequiredChecks({ - requiredContexts: REQUIRED, - workflowJobs: [job("pr-title / pr-title"), job("ci-status")], - checkRuns: [check("pr-title / pr-title"), check("ci-status")], - }); - assert.equal(report.ok, false); - const row = report.results.find( - (entry) => entry.context === "do-not-merge / do-not-merge", - ); - assert.equal(row.verdict, "missing"); -}); - -test("pending: in-flight check-run is not treated as attached success", () => { - const report = reconcileRequiredChecks({ - requiredContexts: ["review / review"], - workflowJobs: [ - job("review / review", { - status: "in_progress", - conclusion: null, - }), - ], - checkRuns: [ - check("review / review", { - status: "in_progress", - conclusion: null, - }), - ], - }); - assert.equal(report.ok, false); - assert.equal(report.results[0].verdict, "pending"); -}); - -test("mismatch: job and check-run conclusions disagree", () => { - const report = reconcileRequiredChecks({ - requiredContexts: ["ci-status"], - workflowJobs: [job("ci-status", { conclusion: "success" })], - checkRuns: [check("ci-status", { conclusion: "failure" })], - }); - assert.equal(report.ok, false); - assert.equal(report.results[0].verdict, "mismatch"); -}); - -test("failed: completed check with failure/cancelled/timed_out is not ok", () => { - for (const conclusion of ["failure", "cancelled", "timed_out"]) { - const alignedFail = reconcileRequiredChecks({ - requiredContexts: ["ci-status"], - workflowJobs: [job("ci-status", { conclusion })], - checkRuns: [check("ci-status", { conclusion })], - }); - assert.equal(alignedFail.ok, false, conclusion); - assert.equal(alignedFail.results[0].verdict, "failed", conclusion); - - const checkOnlyFail = reconcileRequiredChecks({ - requiredContexts: ["external / gate"], - workflowJobs: [], - checkRuns: [check("external / gate", { conclusion })], - }); - assert.equal(checkOnlyFail.ok, false, conclusion); - assert.equal(checkOnlyFail.results[0].verdict, "failed", conclusion); - } -}); - -test("integration_id: wrong app check-run does not satisfy the requirement", () => { - const report = reconcileRequiredChecks({ - requiredContexts: [{ context: "ci-status", integrationId: 15368 }], - workflowJobs: [job("ci-status")], - checkRuns: [check("ci-status", { appId: 999 })], - }); - assert.equal(report.ok, false); - assert.equal(report.results[0].verdict, "divergence"); -}); - -test("integration_id: matching app.id satisfies the requirement", () => { - const report = reconcileRequiredChecks({ - requiredContexts: [{ context: "ci-status", integrationId: 15368 }], - workflowJobs: [job("ci-status")], - checkRuns: [check("ci-status", { appId: 15368 })], - }); - assert.equal(report.ok, true); - assert.equal(report.results[0].verdict, "aligned"); -}); - -test("check_only: attached successful check-run without a workflow job is ok", () => { - const report = reconcileRequiredChecks({ - requiredContexts: ["ci-status"], - workflowJobs: [], - checkRuns: [check("ci-status", { conclusion: "success" })], - }); - assert.equal(report.ok, true); - assert.equal(report.results[0].verdict, "check_only"); -}); - -test("formatReconcileReport names the #399 divergence for operators", () => { - const report = reconcileRequiredChecks({ - requiredContexts: ["do-not-merge / do-not-merge"], - workflowJobs: [job("do-not-merge / do-not-merge")], - checkRuns: [], - }); - const text = formatReconcileReport(report, { - repo: "melodic-software/claude-code-plugins", - sha: "2895890c", - }); - assert.match(text, /divergence/); - assert.match(text, /check=ABSENT/); - assert.match(text, /ci-workflows#399/); - assert.match(text, /::error::/); -}); - -test("classifyContext exposes the #399 wording on job-without-check", () => { - const row = classifyContext( - "pr-title / pr-title", - job("pr-title / pr-title"), - null, - ); - assert.equal(row.verdict, "divergence"); - assert.match(row.detail, /no matching check-run is attached/); -}); - -test("reconcileRequiredChecks rejects empty context lists", () => { - assert.throws( - () => - reconcileRequiredChecks({ - requiredContexts: [], - workflowJobs: [], - checkRuns: [], - }), - (error) => error instanceof UsageError, - ); -}); - -test("CLI fixture mode detects divergence and exits 1", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-reconcile-")); - const checksPath = path.join(dir, "checks.json"); - const jobsPath = path.join(dir, "jobs.json"); - fs.writeFileSync( - checksPath, - JSON.stringify({ - check_runs: [check("pr-title / pr-title"), check("ci-status")], - }), - ); - fs.writeFileSync( - jobsPath, - JSON.stringify({ - jobs: [ - job("pr-title / pr-title"), - job("do-not-merge / do-not-merge"), - job("ci-status"), - ], - }), - ); - - let stdout = ""; - let stderr = ""; - const originalStdoutWrite = process.stdout.write; - const originalStderrWrite = process.stderr.write; - process.stdout.write = (chunk) => { - stdout += chunk; - return true; - }; - process.stderr.write = (chunk) => { - stderr += chunk; - return true; - }; - let code; - try { - code = main([ - "--check-runs-json", - checksPath, - "--jobs-json", - jobsPath, - "--context", - "pr-title / pr-title", - "--context", - "do-not-merge / do-not-merge", - "--context", - "ci-status", - ]); - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } - assert.equal(code, 1); - assert.match(stdout, /do-not-merge \/ do-not-merge: divergence/); - assert.equal(stderr, ""); -}); - -test("CLI fixture mode exits 0 when surfaces align", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-reconcile-")); - const checksPath = path.join(dir, "checks.json"); - const jobsPath = path.join(dir, "jobs.json"); - fs.writeFileSync( - checksPath, - JSON.stringify({ - check_runs: REQUIRED.map((name) => check(name)), - }), - ); - fs.writeFileSync( - jobsPath, - JSON.stringify({ - jobs: REQUIRED.map((name) => job(name)), - }), - ); - let stdout = ""; - const originalStdoutWrite = process.stdout.write; - process.stdout.write = (chunk) => { - stdout += chunk; - return true; - }; - let code; - try { - code = main([ - "--check-runs-json", - checksPath, - "--jobs-json", - jobsPath, - "--context", - "pr-title / pr-title", - "--context", - "do-not-merge / do-not-merge", - "--context", - "ci-status", - ]); - } finally { - process.stdout.write = originalStdoutWrite; - } - assert.equal(code, 0); - assert.match(stdout, /ok: every required context is present/); -}); - -test("CLI fixture: explicit --context wins over --rulesets-json", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-reconcile-")); - const checksPath = path.join(dir, "checks.json"); - const jobsPath = path.join(dir, "jobs.json"); - const rulesetsPath = path.join(dir, "rulesets.json"); - fs.writeFileSync( - checksPath, - JSON.stringify({ check_runs: [check("ci-status")] }), - ); - fs.writeFileSync(jobsPath, JSON.stringify({ jobs: [job("ci-status")] })); - fs.writeFileSync( - rulesetsPath, - JSON.stringify([ - { - enforcement: "active", - target: "branch", - rules: [ - { - type: "required_status_checks", - parameters: { - required_status_checks: [{ context: "from-ruleset" }], - }, - }, - ], - }, - ]), - ); - let stdout = ""; - const originalStdoutWrite = process.stdout.write; - process.stdout.write = (chunk) => { - stdout += chunk; - return true; - }; - let code; - try { - code = main([ - "--check-runs-json", - checksPath, - "--jobs-json", - jobsPath, - "--rulesets-json", - rulesetsPath, - "--context", - "ci-status", - ]); - } finally { - process.stdout.write = originalStdoutWrite; - } - assert.equal(code, 0); - assert.match(stdout, /ci-status: aligned/); - assert.doesNotMatch(stdout, /from-ruleset/); -}); diff --git a/.github/scripts/lefthook-validate.test.sh b/.github/scripts/lefthook-validate.test.sh deleted file mode 100755 index d0501359..00000000 --- a/.github/scripts/lefthook-validate.test.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -good=.github/actions/lefthook-validate/fixtures/good/lefthook.yml -bad=.github/actions/lefthook-validate/fixtures/bad/lefthook.yml - -LEFTHOOK_CONFIG="$good" lefthook validate - -output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/lefthook-validate-bad.txt" -if LEFTHOOK_CONFIG="$bad" lefthook validate >"$output" 2>&1; then - echo "The invalid extended fragment unexpectedly passed Lefthook validation." >&2 - exit 1 -fi -cat -- "$output" -grep -E 'validation failed for (main|secondary) config' "$output" diff --git a/.github/scripts/network-timeout-policy.test.cjs b/.github/scripts/network-timeout-policy.test.cjs index 98449f7e..eac89f15 100644 --- a/.github/scripts/network-timeout-policy.test.cjs +++ b/.github/scripts/network-timeout-policy.test.cjs @@ -120,7 +120,6 @@ test("every shared-installer consumer caches its verified release asset", () => "actionlint", "editorconfig", "gitleaks", - "lefthook-validate", "lychee-offline", "shellcheck", "shfmt", diff --git a/.github/scripts/osv-scan-guard.sh b/.github/scripts/osv-scan-guard.sh deleted file mode 100644 index 636c0df9..00000000 --- a/.github/scripts/osv-scan-guard.sh +++ /dev/null @@ -1,188 +0,0 @@ -# shellcheck shell=bash -set -euo pipefail - -case "${FAIL_ON_VULN:-}" in true | false) ;; *) - echo '::error::fail-on-vuln must resolve to true or false.' - exit 2 - ;; -esac -case "${ALLOW_NO_LOCKFILES:-}" in true | false) ;; *) - echo '::error::allow-no-lockfiles must resolve to true or false.' - exit 2 - ;; -esac -if [[ ! "${SCAN_EXIT:-}" =~ ^[0-9]+$ ]]; then - echo '::error::OSV-Scanner did not report a numeric exit code.' - exit 2 -fi - -valid_sarif=false -if [[ -f "${OSV_RESULTS:-}" && ! -L "${OSV_RESULTS:-}" ]] && - jq -e ' - .version == "2.1.0" - and ((.runs | type) == "array") - and ((.runs | length) > 0) - and all(.runs[]; (type == "object") and ((.results | type) == "array")) - ' "$OSV_RESULTS" >/dev/null 2>&1; then - valid_sarif=true -fi - -annotate_findings() { - local file line message normalized_file uri_safe uri_base64 message_safe message_base64 - escape_data() { - local v="$1" - v=${v//'%'/'%25'} - v=${v//$'\r'/'%0D'} - v=${v//$'\n'/'%0A'} - printf '%s' "$v" - } - # A workflow-command PROPERTY value escapes everything a DATA value does and - # two delimiters besides, so it is exactly `escape_data` plus those two. The - # order matters and is preserved: `%` is encoded first, so the `%` each later - # substitution introduces is left alone. `escape_data` emits no newline and - # can produce none (both CR and LF are already encoded), so the command - # substitution has nothing to strip. - escape_property() { - local v - v="$(escape_data "$1")" - v=${v//':'/'%3A'} - v=${v//','/'%2C'} - printf '%s' "$v" - } - decode_base64_field() { - local encoded="$1" target="$2" decoded status - decoded="$( - set +e - jq -jnr --arg encoded "$encoded" '$encoded | @base64d' 2>/dev/null - status=$? - printf '\036' - exit "$status" - )" || return 1 - decoded="${decoded%$'\036'}" - printf -v "$target" '%s' "$decoded" - } - normalize_sarif_uri() { - local uri="$1" encoded decoded='' remainder prefix hex byte - local workspace candidate resolved relative - - [[ -n "$uri" && -n "${GITHUB_WORKSPACE:-}" ]] || return 1 - workspace="$(realpath -e -- "$GITHUB_WORKSPACE" 2>/dev/null)" || return 1 - [[ -d "$workspace" ]] || return 1 - - if [[ "$uri" == file:///* ]]; then - encoded="${uri#file://}" - elif [[ "$uri" == file://* || "$uri" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]]; then - return 1 - elif [[ "$uri" == /* ]]; then - return 1 - else - encoded="$uri" - fi - [[ -n "$encoded" && "$encoded" != *'?'* && "$encoded" != *'#'* ]] || return 1 - [[ "${encoded,,}" != *'%00'* ]] || return 1 - - remainder="$encoded" - while [[ "$remainder" == *%* ]]; do - prefix="${remainder%%\%*}" - remainder="${remainder#*\%}" - [[ "$remainder" =~ ^([0-9A-Fa-f]{2}) ]] || return 1 - hex="${BASH_REMATCH[1]}" - if ((16#$hex < 32 || 16#$hex == 127)); then - return 1 - fi - printf -v byte '%b' "\\x$hex" - decoded+="$prefix$byte" - remainder="${remainder:2}" - done - decoded+="$remainder" - [[ -n "$decoded" && "$decoded" != *\\* ]] || return 1 - if printf '%s' "$decoded" | LC_ALL=C grep -q '[[:cntrl:]]'; then - return 1 - fi - - if [[ "$uri" == file:///* ]]; then - candidate="$decoded" - else - candidate="$workspace/$decoded" - fi - resolved="$(realpath -e -- "$candidate" 2>/dev/null)" || return 1 - [[ "$resolved" == "$workspace" || "$resolved" == "$workspace/"* ]] || return 1 - if [[ "$resolved" == "$workspace" ]]; then - relative='.' - else - relative="${resolved#"$workspace/"}" - fi - printf '%s' "$relative" - } - while IFS='|' read -r uri_safe uri_base64 line message_safe message_base64; do - message_base64="${message_base64%$'\r'}" - if [[ "$message_safe" == true ]]; then - # shellcheck disable=SC2310 # decoder status selects a safe fallback explicitly. - if ! decode_base64_field "$message_base64" message; then - message='OSV vulnerability finding' - fi - else - message='OSV vulnerability finding' - fi - message="$(escape_data "$message")" - # shellcheck disable=SC2310 # normalization returns status; fallible body commands are checked. - if [[ "$uri_safe" == true ]] && decode_base64_field "$uri_base64" file && normalized_file="$(normalize_sarif_uri "$file")"; then - file="$(escape_property "$normalized_file")" - [[ "$line" =~ ^[1-9][0-9]*$ ]] || line=1 - line="$(escape_property "$line")" - echo "::warning file=$file,line=$line::$message" - else - echo "::warning::$message" - fi - done < <(jq -r ' - [.runs[].results[]][:50][] - | (.locations[0].physicalLocation.artifactLocation.uri // "") as $uri - | (.message.text // "OSV vulnerability finding") as $message - | [ - (if ($uri | type) == "string" then ($uri | explode | all(. >= 32 and . != 127)) else false end), - ($uri | if type == "string" then @base64 else "" end), - (.locations[0].physicalLocation.region.startLine // 1 | if type == "number" and . >= 1 and . == floor then tostring else "1" end), - (if ($message | type) == "string" then ($message | explode | all((. >= 32 or . == 9 or . == 10 or . == 13) and . != 127)) else false end), - ($message | if type == "string" then @base64 else ("OSV vulnerability finding" | @base64) end) - ] - | join("|") - ' "$OSV_RESULTS") -} - -case "$SCAN_EXIT" in -0 | 1) - if [[ "$valid_sarif" != true ]]; then - echo "::error::OSV-Scanner exited $SCAN_EXIT without a valid regular SARIF result." - exit 2 - fi - finding_count="$(jq '[.runs[].results[]] | length' "$OSV_RESULTS")" - if [[ "$SCAN_EXIT" == 0 && "$finding_count" != 0 ]] || [[ "$SCAN_EXIT" == 1 && "$finding_count" == 0 ]]; then - echo "::error::OSV-Scanner exit $SCAN_EXIT disagrees with SARIF finding count $finding_count." - exit 2 - fi - if [[ "$SCAN_EXIT" == 1 ]]; then - annotate_findings - if [[ "$FAIL_ON_VULN" == true ]]; then - echo "::error::OSV-Scanner reported $finding_count vulnerability finding(s)." - exit 1 - fi - echo "::notice::OSV-Scanner reported $finding_count finding(s); advisory mode remains successful." - else - echo 'OSV-Scanner completed without vulnerability findings.' - fi - ;; -128) - if [[ "$ALLOW_NO_LOCKFILES" == true ]]; then - echo 'Empty OSV scan accepted because the caller declared the repository dependency-less.' - elif [[ "$FAIL_ON_VULN" == true ]]; then - echo '::error::OSV-Scanner found no supported dependency sources (exit 128).' - exit 1 - else - echo '::warning::OSV-Scanner found no supported dependency sources (exit 128).' - fi - ;; -*) - echo "::error::OSV-Scanner failed operationally (exit $SCAN_EXIT); results are not trusted." - exit "$SCAN_EXIT" - ;; -esac diff --git a/.github/scripts/osv-scan-guard.test.sh b/.github/scripts/osv-scan-guard.test.sh index 34b09e6d..4dae45fe 100644 --- a/.github/scripts/osv-scan-guard.test.sh +++ b/.github/scripts/osv-scan-guard.test.sh @@ -1,9 +1,22 @@ # shellcheck shell=bash set -euo pipefail -guard="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/osv-scan-guard.sh" +workflow="$(cd "$(dirname "${BASH_SOURCE[0]}")/../workflows" && pwd)/osv-scanner.yml" temporary_directory="$(mktemp -d)" trap 'rm -rf -- "$temporary_directory"' EXIT +guard="$temporary_directory/guard.sh" +# The guard's only copy is the `run:` block of the workflow's classify step. +awk ' + /^ - name: Validate and classify OSV result$/ { step = 1; next } + step && !body && /^ - name: / { step = 0 } + step && /^ run: \|$/ { body = 1; next } + body && NF && !/^ / { exit } + body { sub(/^ /, ""); print } +' "$workflow" >"$guard" +if ! grep -q 'set -euo pipefail' "$guard" || ! grep -q 'results are not trusted' "$guard"; then + echo "could not extract the OSV guard block from $workflow" >&2 + exit 1 +fi results="$temporary_directory/results.sarif" workspace="$temporary_directory/workspace" mkdir -p -- "$workspace/src" diff --git a/.github/scripts/osv-scanner-pin.test.cjs b/.github/scripts/osv-scanner-pin.test.cjs index 9ac79568..35a2781e 100644 --- a/.github/scripts/osv-scanner-pin.test.cjs +++ b/.github/scripts/osv-scanner-pin.test.cjs @@ -1,7 +1,6 @@ "use strict"; const assert = require("node:assert/strict"); -const { spawnSync } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); @@ -67,16 +66,7 @@ test("workflow runs the native binary on a caller-selected runner and verifies S assert.doesNotMatch(workflow, /continue-on-error/u); }); -test("OSV result handling is generated from the tested fail-closed guard", () => { - const renderer = path.join(__dirname, "render-osv-scan-guard.cjs"); - const result = spawnSync(process.execPath, [renderer, "--check"], { - encoding: "utf8", - }); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.equal( - workflow.match(/Source: \.github\/scripts\/osv-scan-guard\.sh/gu)?.length, - 1, - ); +test("OSV result handling fails closed", () => { assert.match( workflow, /operationally \(exit \$SCAN_EXIT\); results are not trusted/u, diff --git a/.github/scripts/render-compose.cjs b/.github/scripts/render-compose.cjs deleted file mode 100644 index 478992bb..00000000 --- a/.github/scripts/render-compose.cjs +++ /dev/null @@ -1,147 +0,0 @@ -"use strict"; - -/** - * Shared primitives for render-*.cjs codegen. - * - * Reusable workflows must ship standalone (no repo-relative `source`), so - * canonical script bodies are inlined between BEGIN/END GENERATED markers at - * render time. This module owns the common mechanics — newline normalize, - * exactly-one ordered block replacement, indentation, and the --check/write - * CLI loop — so per-target renderers only declare source/workflow/markers and - * a bundle transform. - * - * Composition/include of *shared bash primitives into* those sources (issue - * #200 items 1a/1b) is intentionally out of scope here. - */ - -const fs = require("node:fs"); - -function normalizeNewlines(text) { - return String(text).replaceAll("\r\n", "\n"); -} - -function indentLines(lines, indentation) { - return lines.map((line) => - line.length === 0 ? "" : `${indentation}${line}`, - ); -} - -/** - * Locate a single ordered begin/end marker pair. Rejects missing, reversed, - * or duplicated markers (the stricter pulumi-era contract). - */ -function findExactlyOneOrderedBlock(text, beginMarker, endMarker) { - const start = text.indexOf(beginMarker); - const end = text.indexOf(endMarker); - if ( - start < 0 || - end <= start || - text.indexOf(beginMarker, start + beginMarker.length) >= 0 || - text.indexOf(endMarker, end + endMarker.length) >= 0 - ) { - throw new Error( - "workflow must contain exactly one ordered generated block", - ); - } - return { start, end }; -} - -/** - * Replace the region from beginMarker through endMarker (inclusive) with - * `replacement`, which must itself include both markers. - */ -function replaceGeneratedBlock(text, beginMarker, endMarker, replacement) { - const { start, end } = findExactlyOneOrderedBlock( - text, - beginMarker, - endMarker, - ); - return `${text.slice(0, start)}${replacement}${text.slice(end + endMarker.length)}`; -} - -/** - * Apply a target's bundle transform and splice it into a workflow document. - * - * @param {object} options - * @param {string} options.workflow - * @param {string} options.source - * @param {string} options.beginMarker fully-indented begin marker as it - * appears in the workflow file - * @param {string} options.endMarker fully-indented end marker - * @param {(source: string) => string} options.bundle returns the full - * indented block including begin/end markers - * @param {string} [options.name] used only in error messages - */ -function renderWorkflow({ - workflow, - source, - beginMarker, - endMarker, - bundle, - name, -}) { - try { - return replaceGeneratedBlock( - workflow, - beginMarker, - endMarker, - bundle(source), - ); - } catch (error) { - if (name && error instanceof Error) { - error.message = `${name}: ${error.message}`; - } - throw error; - } -} - -/** - * Write expected content, or in --check mode report drift without writing. - * - * @returns {boolean} true when in sync / written; false when check saw drift - */ -function writeOrCheck({ filePath, current, expected, check, driftMessage }) { - if (check) { - if (current !== expected) { - process.stderr.write(driftMessage); - return false; - } - return true; - } - fs.writeFileSync(filePath, expected, "utf8"); - return true; -} - -/** - * Run a multi-file render/check pass. - * - * @param {object} options - * @param {boolean} options.check - * @param {Array<{ - * filePath: string, - * current: string, - * expected: string, - * driftMessage: string, - * }>} options.files - * @returns {number} process exit code (0 ok, 1 drift under --check) - */ -function runRenderPass({ check, files }) { - let drift = false; - for (const file of files) { - const ok = writeOrCheck({ ...file, check }); - if (!ok) { - drift = true; - } - } - return check && drift ? 1 : 0; -} - -module.exports = Object.freeze({ - normalizeNewlines, - indentLines, - findExactlyOneOrderedBlock, - replaceGeneratedBlock, - renderWorkflow, - writeOrCheck, - runRenderPass, -}); diff --git a/.github/scripts/render-compose.test.cjs b/.github/scripts/render-compose.test.cjs deleted file mode 100644 index 9401f8dd..00000000 --- a/.github/scripts/render-compose.test.cjs +++ /dev/null @@ -1,225 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const { - findExactlyOneOrderedBlock, - indentLines, - normalizeNewlines, - replaceGeneratedBlock, - renderWorkflow, - runRenderPass, - writeOrCheck, -} = require("./render-compose.cjs"); -const { - TARGETS, - bundledScript, - filesForTarget, - main, - render, -} = require("./render.cjs"); - -const scriptsDirectory = __dirname; -const repositoryRoot = path.join(scriptsDirectory, "..", ".."); - -test("manifest covers every render-*.cjs thin wrapper still shipped", () => { - const wrappers = fs - .readdirSync(scriptsDirectory) - .filter( - (name) => - /^render-.+\.cjs$/u.test(name) && !name.startsWith("render-compose"), - ) - .sort(); - assert.deepEqual(wrappers, ["render-osv-scan-guard.cjs"]); - assert.deepEqual(Object.keys(TARGETS).sort(), ["osv-scan-guard"]); -}); - -test("exactly-one ordered block rejects missing, reversed, and duplicate markers", () => { - const begin = "BEGIN"; - const end = "END"; - assert.throws( - () => findExactlyOneOrderedBlock("no markers", begin, end), - /exactly one ordered generated block/u, - ); - assert.throws( - () => findExactlyOneOrderedBlock("END before BEGIN", begin, end), - /exactly one ordered generated block/u, - ); - assert.throws( - () => findExactlyOneOrderedBlock("BEGIN one END BEGIN two END", begin, end), - /exactly one ordered generated block/u, - ); - const ok = findExactlyOneOrderedBlock("pre BEGIN body END post", begin, end); - assert.equal(ok.start, "pre ".length); - assert.equal(ok.end, "pre BEGIN body ".length); -}); - -test("replaceGeneratedBlock preserves surrounding text", () => { - const out = replaceGeneratedBlock( - "pre\nBEGIN\nold\nEND\npost\n", - "BEGIN", - "END", - "BEGIN\nnew\nEND", - ); - assert.equal(out, "pre\nBEGIN\nnew\nEND\npost\n"); -}); - -test("indentLines leaves blank lines blank", () => { - assert.deepEqual(indentLines(["a", "", "b"], " "), [" a", "", " b"]); -}); - -test("normalizeNewlines collapses CRLF", () => { - assert.equal(normalizeNewlines("a\r\nb\r\n"), "a\nb\n"); -}); - -test("renderWorkflow names failures after the workflow file", () => { - assert.throws( - () => - renderWorkflow({ - workflow: "no markers here", - source: "echo hi\n", - beginMarker: "BEGIN", - endMarker: "END", - bundle: () => "BEGIN\nEND", - name: "example.yml", - }), - /example\.yml: workflow must contain exactly one ordered generated block/u, - ); -}); - -test("writeOrCheck reports drift under --check without writing", () => { - const filePath = path.join( - os.tmpdir(), - `render-compose-scratch-${process.pid}.txt`, - ); - fs.writeFileSync(filePath, "current\n", "utf8"); - const chunks = []; - const originalWrite = process.stderr.write; - process.stderr.write = (chunk) => { - chunks.push(String(chunk)); - return true; - }; - try { - assert.equal( - writeOrCheck({ - filePath, - current: "current\n", - expected: "expected\n", - check: true, - driftMessage: "drifted\n", - }), - false, - ); - assert.equal(fs.readFileSync(filePath, "utf8"), "current\n"); - assert.deepEqual(chunks, ["drifted\n"]); - } finally { - process.stderr.write = originalWrite; - fs.rmSync(filePath, { force: true }); - } -}); - -test("writeOrCheck writes expected content when check is false", () => { - const filePath = path.join( - os.tmpdir(), - `render-compose-write-${process.pid}.txt`, - ); - fs.writeFileSync(filePath, "stale\n", "utf8"); - try { - assert.equal( - writeOrCheck({ - filePath, - current: "stale\n", - expected: "fresh\n", - check: false, - driftMessage: "should-not-print\n", - }), - true, - ); - assert.equal(fs.readFileSync(filePath, "utf8"), "fresh\n"); - } finally { - fs.rmSync(filePath, { force: true }); - } -}); - -test("runRenderPass returns exit 1 when any file drifts under --check", () => { - const code = runRenderPass({ - check: true, - files: [ - { - filePath: "/dev/null", - current: "a", - expected: "a", - driftMessage: "a\n", - }, - { - filePath: "/dev/null", - current: "b", - expected: "c", - driftMessage: "b-drift\n", - }, - ], - }); - assert.equal(code, 1); -}); - -test("each thin wrapper --check stays green (no generated-output change)", () => { - for (const wrapper of ["render-osv-scan-guard.cjs"]) { - const result = spawnSync( - process.execPath, - [path.join(scriptsDirectory, wrapper), "--check"], - { encoding: "utf8" }, - ); - assert.equal( - result.status, - 0, - `${wrapper}: ${result.stdout}\n${result.stderr}`, - ); - } -}); - -test("render.cjs --check covers the full manifest", () => { - const result = spawnSync( - process.execPath, - [path.join(scriptsDirectory, "render.cjs"), "--check"], - { encoding: "utf8" }, - ); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); -}); - -test("manifest render matches thin-wrapper exports byte-for-byte on fixtures", () => { - for (const [id, target] of Object.entries(TARGETS)) { - const source = fs.readFileSync( - path.join(scriptsDirectory, target.source), - "utf8", - ); - const viaManifest = bundledScript(id, source); - for (const workflowName of target.workflows) { - const workflow = fs.readFileSync( - path.join(repositoryRoot, ".github", "workflows", workflowName), - "utf8", - ); - const expected = render(id, workflow, source, workflowName); - assert.ok( - expected.includes(viaManifest), - `${id}/${workflowName}: bundled block missing from render output`, - ); - assert.equal( - filesForTarget(id).find((file) => file.filePath.endsWith(workflowName)) - ?.expected, - normalizeNewlines(expected), - ); - } - } -}); - -test("main rejects unknown target ids", () => { - assert.throws( - () => main(["node", "render.cjs", "not-a-real-target"]), - /unknown render target 'not-a-real-target'/u, - ); -}); diff --git a/.github/scripts/render-osv-scan-guard.cjs b/.github/scripts/render-osv-scan-guard.cjs deleted file mode 100644 index c4232b16..00000000 --- a/.github/scripts/render-osv-scan-guard.cjs +++ /dev/null @@ -1,18 +0,0 @@ -"use strict"; - -const { bundledScript, cli, render } = require("./render.cjs"); - -function targetBundledScript(source) { - return bundledScript("osv-scan-guard", source); -} - -function targetRender(workflow, source) { - return render("osv-scan-guard", workflow, source); -} - -module.exports = Object.freeze({ - bundledScript: targetBundledScript, - render: targetRender, -}); - -cli("osv-scan-guard"); diff --git a/.github/scripts/render.cjs b/.github/scripts/render.cjs deleted file mode 100644 index 3c642be9..00000000 --- a/.github/scripts/render.cjs +++ /dev/null @@ -1,174 +0,0 @@ -"use strict"; - -/** - * Data-driven render entrypoint for workflow codegen (#200 / 1c). - * - * Manifest entries declare {source, workflows, markers} plus a small bundle - * strategy for target-specific transforms (shell source header, shellcheck - * directive strip, github-script IIFE wrap). Shared splice/check mechanics - * live in render-compose.cjs. - * - * Usage: - * node .github/scripts/render.cjs [--check] # all targets - * node .github/scripts/render.cjs [--check] # one target - * node .github/scripts/render-.cjs [--check] # thin wrappers - */ - -const fs = require("node:fs"); -const path = require("node:path"); -const { - indentLines, - normalizeNewlines, - renderWorkflow, - runRenderPass, -} = require("./render-compose.cjs"); - -const scriptsDirectory = __dirname; -const workflowsDirectory = path.join(scriptsDirectory, "..", "workflows"); - -function shellLines(source) { - return normalizeNewlines(source).trimEnd().split("\n"); -} - -function bundleShellWithSourceHeader({ - beginBare, - endBare, - sourceComment, - indentation, -}) { - return (source) => - indentLines( - [beginBare, sourceComment, ...shellLines(source), endBare], - indentation, - ).join("\n"); -} - -/** - * @typedef {object} RenderTarget - * @property {string} id - * @property {string} source relative to .github/scripts - * @property {string[]} workflows basename list under .github/workflows - * @property {string} beginMarker fully-indented begin marker in the workflow - * @property {string} endMarker fully-indented end marker in the workflow - * @property {(source: string) => string} bundle - * @property {(workflowName: string) => string} driftMessage - */ - -/** @type {Record} */ -const TARGETS = Object.freeze({ - "osv-scan-guard": Object.freeze({ - id: "osv-scan-guard", - source: "osv-scan-guard.sh", - workflows: Object.freeze(["osv-scanner.yml"]), - beginMarker: " # BEGIN GENERATED OSV SCAN GUARD - DO NOT EDIT", - endMarker: " # END GENERATED OSV SCAN GUARD", - bundle: bundleShellWithSourceHeader({ - beginBare: "# BEGIN GENERATED OSV SCAN GUARD - DO NOT EDIT", - endBare: "# END GENERATED OSV SCAN GUARD", - sourceComment: "# Source: .github/scripts/osv-scan-guard.sh", - indentation: " ", - }), - driftMessage: () => - "osv-scanner.yml is out of sync; run node .github/scripts/render-osv-scan-guard.cjs\n", - }), -}); - -function requireTarget(id) { - const target = TARGETS[id]; - if (!target) { - const known = Object.keys(TARGETS).sort().join(", "); - throw new Error(`unknown render target '${id}' (known: ${known})`); - } - return target; -} - -function bundledScript(id, source) { - return requireTarget(id).bundle(source); -} - -function render(id, workflow, source, name) { - const target = requireTarget(id); - return renderWorkflow({ - workflow, - source, - beginMarker: target.beginMarker, - endMarker: target.endMarker, - bundle: target.bundle, - name: name ?? target.workflows[0], - }); -} - -function filesForTarget(id) { - const target = requireTarget(id); - const sourcePath = path.join(scriptsDirectory, target.source); - // Source is normalized inside each bundle strategy when splitting lines. - // Workflows are read/written as raw bytes so --check stays byte-identical - // to the historical render-*.cjs behavior (no whole-file CRLF rewrite). - const source = fs.readFileSync(sourcePath, "utf8"); - return target.workflows.map((workflowName) => { - const filePath = path.join(workflowsDirectory, workflowName); - const current = fs.readFileSync(filePath, "utf8"); - const expected = render(id, current, source, workflowName); - return { - filePath, - current, - expected, - driftMessage: target.driftMessage(workflowName), - }; - }); -} - -function runTarget(id, argv = process.argv) { - const check = argv.includes("--check"); - const code = runRenderPass({ check, files: filesForTarget(id) }); - if (code !== 0) { - process.exitCode = code; - } - return code; -} - -function runAll(argv = process.argv) { - const check = argv.includes("--check"); - const files = Object.keys(TARGETS).flatMap((id) => filesForTarget(id)); - const code = runRenderPass({ check, files }); - if (code !== 0) { - process.exitCode = code; - } - return code; -} - -/** - * Thin-wrapper entry: run one target's CLI (always, matching historical - * render-*.cjs top-level behavior). - */ -function cli(id, argv = process.argv) { - return runTarget(id, argv); -} - -function parseArgs(argv) { - const positional = argv.slice(2).filter((arg) => !arg.startsWith("-")); - return { targetId: positional[0] ?? null }; -} - -function main(argv = process.argv) { - const { targetId } = parseArgs(argv); - if (targetId) { - return runTarget(targetId, argv); - } - return runAll(argv); -} - -module.exports = Object.freeze({ - TARGETS, - bundledScript, - render, - filesForTarget, - runTarget, - runAll, - cli, - main, -}); - -if (require.main === module) { - main(); -} diff --git a/.github/scripts/resolve-cancelled-prerequisite.cjs b/.github/scripts/resolve-cancelled-prerequisite.cjs deleted file mode 100644 index f6e72982..00000000 --- a/.github/scripts/resolve-cancelled-prerequisite.cjs +++ /dev/null @@ -1,61 +0,0 @@ -"use strict"; - -// Discriminate a timed-out prerequisite job from a routine concurrency -// supersede. GitHub collapses job timeouts into -// `needs..result == cancelled`, but the Actions Jobs REST API exposes -// distinct `conclusion: timed_out` (see cancelled-prerequisite discrimination). - -/** - * Resolve whether a delivered `needs.*.result == cancelled` prerequisite - * should proceed to validation or fail closed. - * - * Heuristic (timed-out vs cancelled discrimination): - * 1. Any `timed_out` job in the run means a prerequisite hit its own ceiling - * rather than being superseded, so fail closed. - * 2. Otherwise proceed (true cancel / supersede). - * - * Until ci-perf Phase 7 the run's routing prefix job was matched by name and - * preferred over an unrelated `timed_out` job. That prefix job no longer - * exists in any consumer, so the fail-closed rule applies to the whole run. - * - * Callers must pass the complete job list from - * `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs`. A non-array input - * represents a failed lookup and fails closed. - * - * @param {Array<{name?: string, conclusion?: string|null, status?: string}>} jobs - * @returns {{ outcome: "proceed"|"fail", reason: string, detail: string }} - */ -function resolveCancelledPrerequisite(jobs) { - if (!Array.isArray(jobs)) { - return { - outcome: "fail", - reason: "lookup-failed", - detail: "workflow jobs response is not an array", - }; - } - - const terminalJobs = jobs.filter( - (job) => - job && - typeof job === "object" && - (job.status === "completed" || typeof job.conclusion === "string"), - ); - - if (terminalJobs.some((job) => job.conclusion === "timed_out")) { - return { - outcome: "fail", - reason: "timed_out", - detail: "run contains a timed_out job (fail-closed heuristic)", - }; - } - - return { - outcome: "proceed", - reason: "cancelled", - detail: "no timed_out job in the run; treating as true cancel", - }; -} - -module.exports = { - resolveCancelledPrerequisite, -}; diff --git a/.github/scripts/resolve-cancelled-prerequisite.test.cjs b/.github/scripts/resolve-cancelled-prerequisite.test.cjs deleted file mode 100644 index a967e457..00000000 --- a/.github/scripts/resolve-cancelled-prerequisite.test.cjs +++ /dev/null @@ -1,58 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const test = require("node:test"); - -const { - resolveCancelledPrerequisite, -} = require("./resolve-cancelled-prerequisite.cjs"); - -test("resolveCancelledPrerequisite fails closed when lookup data is missing", () => { - assert.deepEqual(resolveCancelledPrerequisite(null), { - outcome: "fail", - reason: "lookup-failed", - detail: "workflow jobs response is not an array", - }); -}); - -test("resolveCancelledPrerequisite fails closed when a prerequisite timed out", () => { - assert.deepEqual( - resolveCancelledPrerequisite([ - { name: "build", status: "completed", conclusion: "timed_out" }, - { name: "pr-title", status: "completed", conclusion: "success" }, - ]), - { - outcome: "fail", - reason: "timed_out", - detail: "run contains a timed_out job (fail-closed heuristic)", - }, - ); -}); - -test("resolveCancelledPrerequisite proceeds when the run was truly cancelled", () => { - assert.deepEqual( - resolveCancelledPrerequisite([ - { name: "build", status: "completed", conclusion: "cancelled" }, - { name: "pr-title", status: "in_progress", conclusion: null }, - ]), - { - outcome: "proceed", - reason: "cancelled", - detail: "no timed_out job in the run; treating as true cancel", - }, - ); -}); - -test("resolveCancelledPrerequisite ignores jobs that have not reached a terminal state", () => { - assert.deepEqual( - resolveCancelledPrerequisite([ - { name: "build", status: "completed", conclusion: "success" }, - { name: "pr-title", status: "in_progress", conclusion: null }, - ]), - { - outcome: "proceed", - reason: "cancelled", - detail: "no timed_out job in the run; treating as true cancel", - }, - ); -}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4130cc85..34269318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,6 @@ jobs: **/*.ts **/*.tsx **/biome.json - **/tsconfig.json dotnet: .github/** fixtures/dotnet/** @@ -171,9 +170,6 @@ jobs: actionlint: .github/** fixtures/composite-action/** - lefthook-validate: - .github/** - **/lefthook.yml jsonschema: .github/** selector-contract: @@ -375,23 +371,6 @@ jobs: paths: fixtures/typescript/good .github/scripts config: fixtures/typescript/good/biome.json - tsc: - needs: changes - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['typescript'] != 'false' }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Type-check TypeScript - # Point tsc at a self-contained fixture project; this lane verifies the - # action contract without mirroring the standards catalog. - uses: ./.github/actions/tsc - with: - project: fixtures/typescript/good/tsconfig.json - dotnet-build: needs: changes if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['dotnet'] != 'false' }} @@ -465,23 +444,6 @@ jobs: - name: Prove a broken composite shell block fails run: bash .github/scripts/composite-run-shellcheck.test.sh - lefthook-validate: - needs: changes - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['lefthook-validate'] != 'false' }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Validate composed Lefthook config - uses: ./.github/actions/lefthook-validate - with: - config-file: .github/actions/lefthook-validate/fixtures/good/lefthook.yml - - name: Prove invalid extended fragments fail - run: bash .github/scripts/lefthook-validate.test.sh - jsonschema: needs: changes if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['jsonschema'] != 'false' }} @@ -680,7 +642,7 @@ jobs: # `changes` is aggregated alongside the lanes it gates: a failed detection # job fails ci-status even though every gated lane fails open and runs, so # a broken filter config cannot ride the fallback to green indefinitely. - needs: [changes, checks, composites-head, powershell, reference-integrity, ruff, pyright, biome, tsc, dotnet-build, dotnet-format, shellcheck, shfmt, selector-contract, actionlint, lefthook-validate, jsonschema, action-metadata-filename, go-quality-dogfood, zizmor, osv-scanner] + needs: [changes, checks, composites-head, powershell, reference-integrity, ruff, pyright, biome, dotnet-build, dotnet-format, shellcheck, shfmt, selector-contract, actionlint, jsonschema, action-metadata-filename, go-quality-dogfood, zizmor, osv-scanner] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -727,4 +689,4 @@ jobs: # ceiling costs nothing in the normal case: the poll ends as soon as # a verdict settles or nothing is left in flight. carry-forward-wait-seconds: '840' - results: ${{ needs.changes.result }} ${{ needs.checks.result }} ${{ needs.composites-head.result }} ${{ needs.powershell.result }} ${{ needs.reference-integrity.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.tsc.result }} ${{ needs.dotnet-build.result }} ${{ needs.dotnet-format.result }} ${{ needs.shellcheck.result }} ${{ needs.shfmt.result }} ${{ needs.selector-contract.result }} ${{ needs.actionlint.result }} ${{ needs.lefthook-validate.result }} ${{ needs.jsonschema.result }} ${{ needs.action-metadata-filename.result }} ${{ needs.go-quality-dogfood.result }} ${{ needs.zizmor.result }} ${{ needs.osv-scanner.result }} + results: ${{ needs.changes.result }} ${{ needs.checks.result }} ${{ needs.composites-head.result }} ${{ needs.powershell.result }} ${{ needs.reference-integrity.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.dotnet-build.result }} ${{ needs.dotnet-format.result }} ${{ needs.shellcheck.result }} ${{ needs.shfmt.result }} ${{ needs.selector-contract.result }} ${{ needs.actionlint.result }} ${{ needs.jsonschema.result }} ${{ needs.action-metadata-filename.result }} ${{ needs.go-quality-dogfood.result }} ${{ needs.zizmor.result }} ${{ needs.osv-scanner.result }} diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index b8bab1e4..e87c105c 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -160,8 +160,6 @@ jobs: SCAN_EXIT: ${{ steps.scan.outputs.exit-code }} shell: bash run: | - # BEGIN GENERATED OSV SCAN GUARD - DO NOT EDIT - # Source: .github/scripts/osv-scan-guard.sh # shellcheck shell=bash set -euo pipefail @@ -350,4 +348,3 @@ jobs: exit "$SCAN_EXIT" ;; esac - # END GENERATED OSV SCAN GUARD diff --git a/README.md b/README.md index 123fab48..8154dd89 100644 --- a/README.md +++ b/README.md @@ -166,15 +166,6 @@ consumer to audit it. workflow files, with the canonical checksum-pinned ShellCheck release installed explicitly so embedded shell validation is identical on hosted and self-hosted workers. -- `.github/actions/lefthook-validate` — installs a checksum-pinned Lefthook - binary and runs its official - [`validate` command][lefthook-validate] against the caller's fully loaded - config. Native discovery is the default; `config-file` selects an explicit - main config through Lefthook's documented [`LEFTHOOK_CONFIG` override][lefthook-config]. - [`extends` fragments][lefthook-extends], remotes, and the matching local config - are still loaded. The version and checksum inputs let a caller align the gate - with an older consumer pin when necessary. This is a composed schema/load - gate; Lefthook does not define it as a command or glob behavior test. - `.github/actions/check-jsonschema` — check-jsonschema validation of JSON/YAML against one schema per call (call once per schema group). - `.github/actions/ci-status` — aggregates a caller-built `needs.*.result` string @@ -391,8 +382,6 @@ consumer to audit it. - `.github/actions/biome` — Biome lint + format-check over the repo's JS/TS (via `npx`; `biome ci --error-on-warnings`, emits `--reporter=github` annotations). -- `.github/actions/tsc` — TypeScript `tsc --noEmit` type-check over the repo's - TypeScript (via `npx`). - `.github/actions/dotnet-build` — builds .NET projects with Roslyn analyzers and code-style enforced as warnings-as-errors (the analysis owner: code-quality `CAxxxx`, code-style `IDExxxx`, nullable, and compiler warnings). Restores in @@ -950,9 +939,6 @@ for repositories with a genuinely different policy. The small configs under `fixtures/` exist only to exercise action and CI-check contracts; they are not mirrors of the standards catalog. -[lefthook-config]: https://lefthook.dev/usage/envs/LEFTHOOK_CONFIG/ -[lefthook-extends]: https://lefthook.dev/configuration/extends/ -[lefthook-validate]: https://lefthook.dev/usage/commands/validate/ [nested-pin-discussion]: https://github.com/orgs/community/discussions/70237 [osv-installation]: https://google.github.io/osv-scanner/installation/ [osv-release-v2-5]: https://github.com/google/osv-scanner/releases/tag/v2.5.1 diff --git a/docs/topics/claude-review-lanes/security-review-absent-mitigation.md b/docs/topics/claude-review-lanes/security-review-absent-mitigation.md index 362e123a..e71ec2d8 100644 --- a/docs/topics/claude-review-lanes/security-review-absent-mitigation.md +++ b/docs/topics/claude-review-lanes/security-review-absent-mitigation.md @@ -31,8 +31,7 @@ Three cooperating pieces, none of which privilege the security lane: resolves the live head via API, checks it out, and runs the always-report lane so a real check attaches. -2. **`security-review-absent-mitigate.cjs`** — extends the - `check-run-reconcile` taxonomy (`#399` / `#422`) to find open PRs whose +2. **`security-review-absent-mitigate.cjs`** — finds open PRs whose required security-review context has no commit check-run past a grace window, then: - `report` — print findings diff --git a/fixtures/typescript/good/tsconfig.json b/fixtures/typescript/good/tsconfig.json deleted file mode 100644 index 4cdfcc2a..00000000 --- a/fixtures/typescript/good/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "strict": true, - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true - }, - "include": ["example.ts"] -}