From 7b6dd80a880d95722a94d3da75af7425029d7ba8 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 28 Oct 2026 07:56:19 +0100 Subject: [PATCH 1/6] feat: verify explicitly reviewed checkout source consumers --- .changeset/reviewed-checkout-sources.md | 5 + packages/oxlint-config/README.md | 34 +++ .../src/toolchain-checkout-sources.test.ts | 178 +++++++++++++++ .../src/toolchain-checkout-sources.ts | 205 ++++++++++++++++++ packages/oxlint-config/src/toolchain-guard.ts | 18 +- .../oxlint-config/src/toolchain-runtime.ts | 52 +++++ 6 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 .changeset/reviewed-checkout-sources.md create mode 100644 packages/oxlint-config/src/toolchain-checkout-sources.test.ts create mode 100644 packages/oxlint-config/src/toolchain-checkout-sources.ts diff --git a/.changeset/reviewed-checkout-sources.md b/.changeset/reviewed-checkout-sources.md new file mode 100644 index 00000000..a08b7f40 --- /dev/null +++ b/.changeset/reviewed-checkout-sources.md @@ -0,0 +1,5 @@ +--- +"@stll/oxlint-config": minor +--- + +Support exact reviewed checkout-source declarations for historical release and delegated workflow consumers. Bind the checkout reference, destination, condition and consumer, verify the last overlapping writer, and reject stale declarations or changed provenance. diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index 0263ee66..56fe6346 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -254,6 +254,40 @@ literal-ref checkouts, incorrect action pins and conditional/error-tolerant checkouts still fail. Local-action provenance is independent of these declarations. Review the producer named in the reason whenever the selector changes. +Historical release consumers and delegated jobs can bind a reviewed checkout +selection explicitly when the default current-source proof cannot resolve it: + +```json +{ + "checkoutSources": [ + { + "path": ".github/workflows/release.yml", + "job": "build", + "checkout": "release-source", + "consumer": "build-release", + "destination": ".", + "ref": "${{ needs.resolve.outputs.sha }}", + "condition": null, + "selector": ".github/actions/build", + "reason": "The resolver verifies the release commit; build its action from that exact snapshot." + } + ] +} +``` + +Both step IDs must be unique in the named job. The reference, destination and +checkout condition must match exactly; a conditional checkout requires the +consumer to have the identical condition. The selected checkout must precede +the consumer, use the approved checkout pin and the same repository, retain +cleaning, and omit `continue-on-error`. A later overlapping or unknown checkout +destination before the consumer invalidates the proof. An earlier writer is +allowed only because the selected checkout replaces it; writers after the +consumer do not affect its selection. Sparse paths must include the selected +file or action directory without expressions or glob patterns. Version-file +selectors still require the tool's supported filename. This declaration reports +runtime delegation; it does not claim a historical manifest uses today's pin. +Unused, changed, or ambiguous declarations fail configuration validation. + Run the installed checker from the repository root in CI: ```sh diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts new file mode 100644 index 00000000..676738b7 --- /dev/null +++ b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from "bun:test"; +import { stringify } from "yaml"; + +import { + type CheckoutSource, + parseCheckoutSources, + proveCheckoutSource, +} from "./toolchain-checkout-sources"; +import { checkRuntimeFile } from "./toolchain-runtime"; + +const sha = "a".repeat(40); +const declaration = { + path: ".github/workflows/release.yml", + job: "build", + checkout: "source", + consumer: "build", + destination: ".", + ref: "${{ needs.resolve.outputs.sha }}", + condition: null, + selector: ".github/actions/build", + reason: "Build the resolved release snapshot, not the workflow revision.", +} satisfies CheckoutSource; +const checkout = { + id: "source", + uses: `actions/checkout@${sha}`, + with: { ref: declaration.ref, "persist-credentials": false }, +}; +const consumer = { id: "build", uses: "./.github/actions/build" }; +const workflow = (steps: unknown[]) => ({ jobs: { build: { steps } } }); +const prove = (steps: unknown[], entry: CheckoutSource = declaration) => + proveCheckoutSource({ + declaration: entry, + workflow: workflow(steps), + checkoutSha: sha, + selector: declaration.selector, + }); + +test("exact reviewed selection permits historical source and sequential replacement", () => { + expect(prove([checkout, consumer])).toBe(true); + expect( + prove([ + { ...checkout, id: "old", with: { ref: "main" } }, + checkout, + consumer, + ]), + ).toBe(true); + expect(prove([checkout, consumer, { ...checkout, id: "later" }])).toBe(true); + const condition = "steps.resolve.outputs.build == 'true'"; + expect( + prove( + [ + { ...checkout, if: condition }, + { ...consumer, if: condition }, + ], + { ...declaration, condition }, + ), + ).toBe(true); +}); + +test("every changed source field fails closed", () => { + for (const changed of [ + { ...checkout, id: "other" }, + { ...checkout, uses: `actions/checkout@${"b".repeat(40)}` }, + { ...checkout, if: "always()" }, + { ...checkout, "continue-on-error": false }, + { ...checkout, with: { ...checkout.with, ref: "main" } }, + { ...checkout, with: { ...checkout.with, path: "other" } }, + { ...checkout, with: { ...checkout.with, repository: "other/repo" } }, + { ...checkout, with: { ...checkout.with, clean: false } }, + { ...checkout, with: { ...checkout.with, CLEAN: false } }, + { + ...checkout, + with: { ...checkout.with, "sparse-checkout": "package.json" }, + }, + { + ...checkout, + with: { ...checkout.with, "sparse-checkout": ".github/**" }, + }, + ]) + expect(prove([changed, consumer])).toBe(false); + expect(prove([consumer, checkout])).toBe(false); + expect(prove([checkout, checkout, consumer])).toBe(false); + expect(prove([checkout, consumer, consumer])).toBe(false); + expect( + prove([{ ...checkout, if: "success()" }, consumer], { + ...declaration, + condition: "success()", + }), + ).toBe(false); +}); + +test("intervening overlapping or unknown writers cannot be waived", () => { + for (const destination of [ + ".", + ".github", + ".github/actions/build", + ".github/actions/build/nested", + "${{ inputs.path }}", + ]) + expect( + prove([ + checkout, + { + ...checkout, + id: "other", + with: { ...checkout.with, path: destination }, + }, + consumer, + ]), + ).toBe(false); + expect( + prove([ + checkout, + { + ...checkout, + id: "other", + with: { ...checkout.with, path: "unrelated" }, + }, + consumer, + ]), + ).toBe(true); +}); + +test("malformed and duplicate declarations cannot suppress diagnostics", () => { + for (const field of [ + "path", + "job", + "checkout", + "consumer", + "destination", + "ref", + "selector", + "reason", + ]) + expect(() => + parseCheckoutSources([{ ...declaration, [field]: "" }]), + ).toThrow(); + expect(() => + parseCheckoutSources([{ ...declaration, condition: true }]), + ).toThrow(); + expect(() => parseCheckoutSources([declaration, declaration])).toThrow(); + expect(() => + parseCheckoutSources([{ ...declaration, extra: true }]), + ).toThrow(); +}); + +test("runtime integration retains undeclared and changed-writer failures", () => { + const check = (steps: unknown[], declarations = [declaration]) => + checkRuntimeFile({ + file: declaration.path, + text: stringify(workflow(steps)).replaceAll( + `actions/checkout@${sha}`, + `actions/checkout@${sha} # v7`, + ), + policy: { + node: "26.x", + python: "3.13.7", + rust: "1.99.1", + bun: "1.4.4", + packages: {}, + actions: { "actions/checkout": { sha, version: "v7" } }, + }, + trackedFiles: new Set([declaration.selector]), + readFile: () => undefined, + checkoutSources: declarations, + }); + expect(check([checkout, consumer])).toEqual([]); + expect( + check([checkout, consumer], []).some( + (diagnostic) => diagnostic.rule === "action-pins", + ), + ).toBe(true); + expect( + check([checkout, { ...checkout, id: "other" }, consumer]).some( + (diagnostic) => diagnostic.rule === "action-pins", + ), + ).toBe(true); +}); diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.ts b/packages/oxlint-config/src/toolchain-checkout-sources.ts new file mode 100644 index 00000000..0a812227 --- /dev/null +++ b/packages/oxlint-config/src/toolchain-checkout-sources.ts @@ -0,0 +1,205 @@ +import path from "node:path"; + +export type CheckoutSource = { + path: string; + job: string; + checkout: string; + consumer: string; + destination: string; + ref: string; + condition: string | null; + selector: string; + reason: string; +}; + +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const identifier = (value: unknown): value is string => + typeof value === "string" && /^[A-Za-z_][\w-]*$/.test(value); +const repositoryPath = (value: unknown): value is string => + typeof value === "string" && + value !== "" && + !/[\\:$*?\s]/.test(value) && + !value.startsWith("/") && + !value.split("/").includes(".."); + +/** A declaration binds one consumer to one reviewed checkout, never an entire rule. */ +export const parseCheckoutSources = (input: unknown): CheckoutSource[] => { + if (input === undefined) return []; + if (!Array.isArray(input)) + throw new Error("checkoutSources must be an array"); + const entries: CheckoutSource[] = []; + const consumers = new Set(); + for (const value of input) { + if ( + !record(value) || + Object.keys(value).some( + (key) => + ![ + "path", + "job", + "checkout", + "consumer", + "destination", + "ref", + "condition", + "selector", + "reason", + ].includes(key), + ) || + typeof value["path"] !== "string" || + !/^\.github\/workflows\/[^/]+\.ya?ml$/.test(value["path"]) || + !identifier(value["job"]) || + !identifier(value["checkout"]) || + !identifier(value["consumer"]) || + !repositoryPath(value["destination"]) || + !repositoryPath(value["selector"]) || + typeof value["ref"] !== "string" || + value["ref"].trim() === "" || + !( + value["condition"] === null || + (typeof value["condition"] === "string" && + value["condition"].trim() !== "") + ) || + typeof value["reason"] !== "string" || + value["reason"].trim() === "" + ) + throw new Error( + "checkout source requires an exact workflow, job, checkout and consumer IDs, destination, ref, condition, selector and reason", + ); + const key = `${value["path"]}:${value["job"]}:${value["consumer"]}`; + if (consumers.has(key)) + throw new Error("checkout source consumers must be unique"); + consumers.add(key); + entries.push({ + path: value["path"], + job: value["job"], + checkout: value["checkout"], + consumer: value["consumer"], + destination: value["destination"], + ref: value["ref"], + condition: value["condition"], + selector: value["selector"], + reason: value["reason"], + }); + } + return entries; +}; + +type CheckoutSourceProof = { + declaration: CheckoutSource; + workflow: unknown; + checkoutSha: string; + selector: string; + repository?: string | undefined; +}; + +/** Resolve the last overlapping writer before this consumer; later writers cannot alter its input. */ +export const proveCheckoutSource = ({ + declaration, + workflow, + checkoutSha, + selector, + repository, +}: CheckoutSourceProof) => { + if ( + selector !== declaration.selector || + !record(workflow) || + !record(workflow["jobs"]) + ) + return false; + const job = workflow["jobs"][declaration.job]; + if (!record(job) || !Array.isArray(job["steps"])) return false; + const steps: unknown[] = job["steps"]; + const consumerIndices = steps.flatMap((step, index) => + record(step) && step["id"] === declaration.consumer ? [index] : [], + ); + const checkoutIndices = steps.flatMap((step, index) => + record(step) && step["id"] === declaration.checkout ? [index] : [], + ); + const consumerIndex = consumerIndices.at(0); + const checkoutIndex = checkoutIndices.at(0); + if ( + consumerIndices.length !== 1 || + checkoutIndices.length !== 1 || + consumerIndex === undefined || + checkoutIndex === undefined || + checkoutIndex >= consumerIndex + ) + return false; + const consumer = steps.at(consumerIndex); + const checkout = steps.at(checkoutIndex); + if ( + !record(consumer) || + !record(checkout) || + checkout["uses"] !== `actions/checkout@${checkoutSha}` || + Object.hasOwn(checkout, "continue-on-error") + ) + return false; + const options = checkout["with"]; + if ( + !record(options) || + Object.keys(options).some((key) => key !== key.toLowerCase()) + ) + return false; + const destination = options["path"] ?? "."; + if ( + destination !== declaration.destination || + options["ref"] !== declaration.ref || + (checkout["if"] ?? null) !== declaration.condition || + (declaration.condition !== null && + consumer["if"] !== declaration.condition) || + (options["clean"] !== undefined && + options["clean"] !== true && + options["clean"] !== "true") || + (options["repository"] !== undefined && + options["repository"] !== "${{ github.repository }}" && + options["repository"] !== repository) + ) + return false; + const selected = path.posix.normalize(selector); + const prefix = path.posix.normalize(declaration.destination); + if (prefix !== "." && !selected.startsWith(`${prefix}/`)) return false; + for (const step of steps.slice(checkoutIndex + 1, consumerIndex)) { + if ( + !record(step) || + typeof step["uses"] !== "string" || + !step["uses"].startsWith("actions/checkout@") + ) + continue; + const withOptions = step["with"]; + const other = record(withOptions) ? (withOptions["path"] ?? ".") : "."; + // Unknown destinations can shadow any selector, even when conditionally written. + if (!repositoryPath(other)) return false; + const otherPrefix = path.posix.normalize(other); + if ( + otherPrefix === "." || + selected === otherPrefix || + selected.startsWith(`${otherPrefix}/`) || + otherPrefix.startsWith(`${selected}/`) + ) + return false; + } + const sparse = options["sparse-checkout"]; + if (sparse !== undefined) { + if (typeof sparse !== "string") return false; + const patterns = sparse + .split(/\r?\n/) + .map((value) => value.trim().replace(/^\//, "").replace(/\/$/, "")) + .filter(Boolean); + if ( + patterns.length === 0 || + patterns.some((value) => !repositoryPath(value)) + ) + return false; + const relative = + prefix === "." ? selected : path.posix.relative(prefix, selected); + if ( + !patterns.some( + (value) => relative === value || relative.startsWith(`${value}/`), + ) + ) + return false; + } + return true; +}; diff --git a/packages/oxlint-config/src/toolchain-guard.ts b/packages/oxlint-config/src/toolchain-guard.ts index 21086b64..56fc1af9 100644 --- a/packages/oxlint-config/src/toolchain-guard.ts +++ b/packages/oxlint-config/src/toolchain-guard.ts @@ -22,6 +22,7 @@ import type { ExpiryScope } from "./dated-entry-scope"; import { readGitTrackedOutput } from "./git-tracked-output"; import { checkRepositoryQuarantine } from "./quarantine-policy"; import { repositoryBun } from "./toolchain-bun"; +import { parseCheckoutSources } from "./toolchain-checkout-sources"; import { checkConsumerChecks, parseConsumerChecks, @@ -110,6 +111,7 @@ export const parseToolchainConfiguration = (input: unknown) => { key !== "engineFloors" && key !== "dynamicSelectors" && key !== "nodeCompatibility" && + key !== "checkoutSources" && key !== "consumerChecks" && key !== "rustExceptions" && key !== "rustInstallFixtures" && @@ -121,7 +123,7 @@ export const parseToolchainConfiguration = (input: unknown) => { (input["optOuts"] !== undefined && !array(input["optOuts"])) ) throw new Error( - "stll-toolchain.json accepts only optOuts, cloud, engineFloors, dynamicSelectors, nodeCompatibility, consumerChecks, rustExceptions, rustInstallFixtures, dependabotIgnores, typescriptAllowlist, formatterStagedSelector and formatterCheckSource declarations", + "stll-toolchain.json accepts only optOuts, cloud, engineFloors, dynamicSelectors, nodeCompatibility, checkoutSources, consumerChecks, rustExceptions, rustInstallFixtures, dependabotIgnores, typescriptAllowlist, formatterStagedSelector and formatterCheckSource declarations", ); const disabled = new Set(); const optOuts = input["optOuts"] ?? []; @@ -179,6 +181,7 @@ export const parseToolchainConfiguration = (input: unknown) => { engineFloors: parseEngineFloors(input["engineFloors"]), dynamicSelectors: parseDynamicSelectors(input["dynamicSelectors"]), nodeCompatibility: parseNodeCompatibility(input["nodeCompatibility"]), + checkoutSources: parseCheckoutSources(input["checkoutSources"]), consumerChecks: parseConsumerChecks(input["consumerChecks"]), dependabotIgnores: parseDependabotIgnores(input["dependabotIgnores"]), typescriptAllowlist: parseTypescriptAllowlist(input["typescriptAllowlist"]), @@ -582,6 +585,9 @@ export const checkToolchain = ({ }); return false; }); + const usedCheckoutSources = new Set< + (typeof configuration.checkoutSources)[number] + >(); for (const [file, text] of Object.entries(files)) diagnostics.push( ...checkRuntimeFile({ @@ -598,6 +604,8 @@ export const checkToolchain = ({ entry, (compatibilityMatches.get(entry) ?? 0) + 1, ), + checkoutSources: configuration.checkoutSources, + onCheckoutSource: (entry) => usedCheckoutSources.add(entry), rustExceptions: configuration.rustExceptions, rustInstallFixtures: configuration.rustInstallFixtures, onRustInstallFixture: (entry) => usedRustFixtures.add(entry), @@ -633,6 +641,14 @@ export const checkToolchain = ({ line: 1, message: `Node compatibility ${entry.workflow}:${entry.job}:${entry.step} must match exactly one setup-node step`, }); + for (const entry of configuration.checkoutSources) + if (!usedCheckoutSources.has(entry)) + diagnostics.push({ + rule: "configuration", + path: "stll-toolchain.json", + line: 1, + message: `checkout source ${entry.path}:${entry.job}:${entry.consumer} must prove its exact selected writer and consumer`, + }); for (const entry of engineFloors) if (!exercisedFloors.has(entry) || mismatchedFloors.has(entry)) diagnostics.push({ diff --git a/packages/oxlint-config/src/toolchain-runtime.ts b/packages/oxlint-config/src/toolchain-runtime.ts index 1e7a3520..47d5d693 100644 --- a/packages/oxlint-config/src/toolchain-runtime.ts +++ b/packages/oxlint-config/src/toolchain-runtime.ts @@ -10,6 +10,10 @@ import { parseDocument, } from "yaml"; +import { + type CheckoutSource, + proveCheckoutSource, +} from "./toolchain-checkout-sources"; import { containerDocumentImages, isComposeDefinitionPath, @@ -85,6 +89,8 @@ type CheckRuntimeFileOptions = { dynamicSelectors?: readonly DynamicSelector[] | undefined; nodeCompatibility?: readonly NodeCompatibility[] | undefined; onNodeCompatibility?: ((entry: NodeCompatibility) => void) | undefined; + checkoutSources?: readonly CheckoutSource[] | undefined; + onCheckoutSource?: ((entry: CheckoutSource) => void) | undefined; onDynamicSelector?: ((entry: DynamicSelector) => void) | undefined; rustExceptions?: readonly RustException[] | undefined; rustInstallFixtures?: readonly RustInstallFixture[] | undefined; @@ -311,6 +317,8 @@ export const checkRuntimeFile = ({ dynamicSelectors = [], nodeCompatibility = [], onNodeCompatibility, + checkoutSources = [], + onCheckoutSource, onDynamicSelector, rustExceptions = [], rustInstallFixtures = [], @@ -1143,6 +1151,41 @@ export const checkRuntimeFile = ({ selector.startsWith(`${prefix}/`)), ); }; + let reviewedWorkflow: unknown; + const reviewedCheckout = ( + selector: string, + tool: RuntimeDelegation["tool"], + line: number, + ) => { + const entry = checkoutSources.find( + (candidate) => + candidate.path === file && + currentLocation === `jobs.${candidate.job}.steps.${candidate.consumer}`, + ); + const checkoutSha = policy.actions["actions/checkout"]?.sha; + if ( + entry === undefined || + checkoutSha === undefined || + !proveCheckoutSource({ + declaration: entry, + workflow: reviewedWorkflow, + checkoutSha, + selector, + repository, + }) + ) + return false; + onCheckoutSource?.(entry); + onDelegated?.({ + path: file, + line, + tool, + selector, + checkoutPath: entry.destination, + ref: entry.ref, + }); + return true; + }; const checkReference = ({ value, tool, line }: RuntimeReferenceOptions) => { const selectors = { node: [".node-version", ".nvmrc"], @@ -1154,6 +1197,12 @@ export const checkRuntimeFile = ({ const selector = staticRepositoryPath(value) ? path.posix.normalize(value) : undefined; + if ( + selector !== undefined && + allowed.includes(path.posix.basename(selector)) && + reviewedCheckout(selector, tool, line) + ) + return; const providers = checkoutProvidersFor(selector); const binding = selector === undefined ? undefined : providers.at(-1); const dynamicSource = providers.some((entry) => entry.dynamicSource); @@ -1531,6 +1580,8 @@ export const checkRuntimeFile = ({ const selector = staticRepositoryPath(value) ? path.posix.normalize(value) : undefined; + if (selector !== undefined && reviewedCheckout(selector, "action", line)) + return; const providers = checkoutProvidersFor(selector); const binding = selector === undefined ? undefined : providers.at(-1); if ( @@ -1889,6 +1940,7 @@ export const checkRuntimeFile = ({ try { // Resolve aliases first with an expansion bound before visiting executable fields. const workflow = document.toJS({ maxAliasCount: 100 }); + reviewedWorkflow = workflow; if (automationKind === "workflow") { for (const finding of checkWorkflowTrust(workflow)) { const jobNode = getNode( From bcf97c5eb9b0a98aa03dc803260d641448688e39 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 28 Oct 2026 08:05:27 +0100 Subject: [PATCH 2/6] fix: share checkout identity and sparse pattern validation --- .../src/toolchain-checkout-sources.test.ts | 50 +++++++++++++++++++ .../src/toolchain-checkout-sources.ts | 15 +++--- .../src/toolchain-checkout-syntax.ts | 13 +++++ .../oxlint-config/src/toolchain-runtime.ts | 10 ++-- 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 packages/oxlint-config/src/toolchain-checkout-syntax.ts diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts index 676738b7..2b2903f0 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts @@ -90,6 +90,18 @@ test("every changed source field fails closed", () => { }); test("intervening overlapping or unknown writers cannot be waived", () => { + for (const action of [ + "actions/checkout", + "Actions/Checkout", + "ACTIONS/CHECKOUT", + ]) + expect( + prove([ + checkout, + { ...checkout, id: "other", uses: `${action}@${sha}` }, + consumer, + ]), + ).toBe(false); for (const destination of [ ".", ".github", @@ -121,6 +133,44 @@ test("intervening overlapping or unknown writers cannot be waived", () => { ).toBe(true); }); +test("sparse coverage rejects every nonliteral pattern even with a matching parent", () => { + for (const pattern of [ + "!.github/actions/build", + ".github/**", + ".github/[ab]", + "${{ inputs.paths }}", + "../.github", + "\\.github", + ]) + expect( + prove([ + { + ...checkout, + with: { + ...checkout.with, + "sparse-checkout-cone-mode": false, + "sparse-checkout": `.github\n${pattern}`, + }, + }, + consumer, + ]), + ).toBe(false); + expect( + prove([ + { + ...checkout, + uses: `Actions/Checkout@${sha}`, + with: { + ...checkout.with, + "sparse-checkout-cone-mode": false, + "sparse-checkout": ".github/actions", + }, + }, + consumer, + ]), + ).toBe(true); +}); + test("malformed and duplicate declarations cannot suppress diagnostics", () => { for (const field of [ "path", diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.ts b/packages/oxlint-config/src/toolchain-checkout-sources.ts index 0a812227..78356fbd 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.ts @@ -1,5 +1,10 @@ import path from "node:path"; +import { + checkoutActionRef, + literalSparseCheckoutPath, +} from "./toolchain-checkout-syntax"; + export type CheckoutSource = { path: string; job: string; @@ -132,7 +137,7 @@ export const proveCheckoutSource = ({ if ( !record(consumer) || !record(checkout) || - checkout["uses"] !== `actions/checkout@${checkoutSha}` || + checkoutActionRef(checkout["uses"]) !== checkoutSha || Object.hasOwn(checkout, "continue-on-error") ) return false; @@ -161,11 +166,7 @@ export const proveCheckoutSource = ({ const prefix = path.posix.normalize(declaration.destination); if (prefix !== "." && !selected.startsWith(`${prefix}/`)) return false; for (const step of steps.slice(checkoutIndex + 1, consumerIndex)) { - if ( - !record(step) || - typeof step["uses"] !== "string" || - !step["uses"].startsWith("actions/checkout@") - ) + if (!record(step) || checkoutActionRef(step["uses"]) === undefined) continue; const withOptions = step["with"]; const other = record(withOptions) ? (withOptions["path"] ?? ".") : "."; @@ -189,7 +190,7 @@ export const proveCheckoutSource = ({ .filter(Boolean); if ( patterns.length === 0 || - patterns.some((value) => !repositoryPath(value)) + patterns.some((value) => !literalSparseCheckoutPath(value)) ) return false; const relative = diff --git a/packages/oxlint-config/src/toolchain-checkout-syntax.ts b/packages/oxlint-config/src/toolchain-checkout-syntax.ts new file mode 100644 index 00000000..0e7d7e6c --- /dev/null +++ b/packages/oxlint-config/src/toolchain-checkout-syntax.ts @@ -0,0 +1,13 @@ +import path from "node:path"; + +/** Repository names are case-insensitive; the selected ref retains its exact spelling. */ +export const checkoutActionRef = (value: unknown) => + typeof value === "string" + ? /^actions\/checkout@(.+)$/i.exec(value)?.at(1) + : undefined; + +export const literalSparseCheckoutPath = (value: string) => + value !== "" && + !/[!$*?[\]\\:]/.test(value) && + !path.posix.isAbsolute(value) && + !value.split("/").includes(".."); diff --git a/packages/oxlint-config/src/toolchain-runtime.ts b/packages/oxlint-config/src/toolchain-runtime.ts index 47d5d693..d92b3212 100644 --- a/packages/oxlint-config/src/toolchain-runtime.ts +++ b/packages/oxlint-config/src/toolchain-runtime.ts @@ -14,6 +14,10 @@ import { type CheckoutSource, proveCheckoutSource, } from "./toolchain-checkout-sources"; +import { + checkoutActionRef, + literalSparseCheckoutPath, +} from "./toolchain-checkout-syntax"; import { containerDocumentImages, isComposeDefinitionPath, @@ -1488,8 +1492,6 @@ export const checkRuntimeFile = ({ while (position < entry.length) { const start = entry.indexOf("${{", position); const literal = entry.slice(position, start < 0 ? entry.length : start); - if (/[!$*?[\]\\:]/.test(literal) || literal.split("/").includes("..")) - return false; literalPath += literal; if (start < 0) break; const expression = expressionAt(entry, start); @@ -1497,7 +1499,7 @@ export const checkRuntimeFile = ({ literalPath += "expression"; position = expression.end; } - return staticRepositoryPath(literalPath); + return literalSparseCheckoutPath(literalPath); }; const checkoutPrefixOf = (node: unknown) => { const destination = getInput(node, "path"); @@ -1908,7 +1910,7 @@ export const checkRuntimeFile = ({ if ( !isScalar(uses) || typeof uses.value !== "string" || - !uses.value.toLowerCase().startsWith("actions/checkout@") + checkoutActionRef(uses.value) === undefined ) continue; const prefix = checkoutPrefixOf(step); From 4aa7531fa826b72e382298039c04d9242fb73d08 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 28 Oct 2026 08:18:08 +0100 Subject: [PATCH 3/6] feat: bind implicit event checkout references explicitly --- packages/oxlint-config/README.md | 2 ++ .../src/toolchain-checkout-sources.test.ts | 9 +++++++++ .../oxlint-config/src/toolchain-checkout-sources.ts | 10 ++++++---- packages/oxlint-config/src/toolchain-runtime.ts | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index 56fe6346..5415c2f1 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -287,6 +287,8 @@ file or action directory without expressions or glob patterns. Version-file selectors still require the tool's supported filename. This declaration reports runtime delegation; it does not claim a historical manifest uses today's pin. Unused, changed, or ambiguous declarations fail configuration validation. +Use `ref: null` only to bind the checkout's omitted, event-default reference; +adding any explicit reference invalidates that declaration. Run the installed checker from the repository root in CI: diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts index 2b2903f0..02cc5e4c 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts @@ -37,6 +37,15 @@ const prove = (steps: unknown[], entry: CheckoutSource = declaration) => test("exact reviewed selection permits historical source and sequential replacement", () => { expect(prove([checkout, consumer])).toBe(true); + const implicit = { ...declaration, ref: null }; + expect(parseCheckoutSources([implicit])).toEqual([implicit]); + expect( + prove( + [{ ...checkout, with: { "persist-credentials": false } }, consumer], + implicit, + ), + ).toBe(true); + expect(prove([checkout, consumer], implicit)).toBe(false); expect( prove([ { ...checkout, id: "old", with: { ref: "main" } }, diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.ts b/packages/oxlint-config/src/toolchain-checkout-sources.ts index 78356fbd..933c7196 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.ts @@ -11,7 +11,7 @@ export type CheckoutSource = { checkout: string; consumer: string; destination: string; - ref: string; + ref: string | null; condition: string | null; selector: string; reason: string; @@ -59,8 +59,10 @@ export const parseCheckoutSources = (input: unknown): CheckoutSource[] => { !identifier(value["consumer"]) || !repositoryPath(value["destination"]) || !repositoryPath(value["selector"]) || - typeof value["ref"] !== "string" || - value["ref"].trim() === "" || + !( + value["ref"] === null || + (typeof value["ref"] === "string" && value["ref"].trim() !== "") + ) || !( value["condition"] === null || (typeof value["condition"] === "string" && @@ -150,7 +152,7 @@ export const proveCheckoutSource = ({ const destination = options["path"] ?? "."; if ( destination !== declaration.destination || - options["ref"] !== declaration.ref || + (options["ref"] ?? null) !== declaration.ref || (checkout["if"] ?? null) !== declaration.condition || (declaration.condition !== null && consumer["if"] !== declaration.condition) || diff --git a/packages/oxlint-config/src/toolchain-runtime.ts b/packages/oxlint-config/src/toolchain-runtime.ts index d92b3212..42c328c5 100644 --- a/packages/oxlint-config/src/toolchain-runtime.ts +++ b/packages/oxlint-config/src/toolchain-runtime.ts @@ -1186,7 +1186,7 @@ export const checkRuntimeFile = ({ tool, selector, checkoutPath: entry.destination, - ref: entry.ref, + ref: entry.ref ?? "", }); return true; }; From a046b8d9cd468069e290fc5402fbaf523b587b2c Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 28 Oct 2026 08:33:52 +0100 Subject: [PATCH 4/6] fix: require stable checkout selection predicates --- packages/oxlint-config/README.md | 4 +- .../src/toolchain-checkout-sources.test.ts | 56 +++++++++++++++++++ .../src/toolchain-checkout-sources.ts | 46 ++++++++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index 5415c2f1..a034ec1c 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -277,7 +277,9 @@ selection explicitly when the default current-source proof cannot resolve it: Both step IDs must be unique in the named job. The reference, destination and checkout condition must match exactly; a conditional checkout requires the -consumer to have the identical condition. The selected checkout must precede +consumer to have the identical condition with immutable operands. Step-output +references must name a unique producer before the checkout; mutable contexts, +status functions and dynamic property access are rejected. The selected checkout must precede the consumer, use the approved checkout pin and the same repository, retain cleaning, and omit `continue-on-error`. A later overlapping or unknown checkout destination before the consumer invalidates the proof. An earlier writer is diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts index 02cc5e4c..8ae71121 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts @@ -46,6 +46,16 @@ test("exact reviewed selection permits historical source and sequential replacem ), ).toBe(true); expect(prove([checkout, consumer], implicit)).toBe(false); + expect( + prove([{ id: "source", uses: checkout.uses }, consumer], implicit), + ).toBe(true); + for (const options of [null, "invalid", []]) + expect( + prove( + [{ id: "source", uses: checkout.uses, with: options }, consumer], + implicit, + ), + ).toBe(false); expect( prove([ { ...checkout, id: "old", with: { ref: "main" } }, @@ -58,6 +68,7 @@ test("exact reviewed selection permits historical source and sequential replacem expect( prove( [ + { id: "resolve", run: "echo build=true >> $GITHUB_OUTPUT" }, { ...checkout, if: condition }, { ...consumer, if: condition }, ], @@ -66,6 +77,51 @@ test("exact reviewed selection permits historical source and sequential replacem ).toBe(true); }); +test("conditional checkout proofs require immutable operands available before checkout", () => { + const condition = "steps.resolve.outputs.build == 'true'"; + const declared = { ...declaration, condition }; + const selected = { ...checkout, if: condition }; + const use = { ...consumer, if: condition }; + const producer = { id: "resolve", run: "echo build=true >> $GITHUB_OUTPUT" }; + expect(prove([selected, producer, use], declared)).toBe(false); + expect(prove([producer, selected, use], declared)).toBe(true); + expect(prove([producer, selected, producer, use], declared)).toBe(false); + for (const predicate of [ + "env.BUILD == 'true'", + "github.action == 'build'", + "failure()", + "cancelled()", + "always()", + "steps['resolve'].outputs.build == 'true'", + ]) + expect( + prove( + [ + producer, + { ...checkout, if: predicate }, + { ...consumer, if: predicate }, + ], + { ...declaration, condition: predicate }, + ), + ).toBe(false); + for (const predicate of [ + "inputs.build == true", + "needs.resolve.outputs.build == 'true'", + "github.event_name == 'workflow_dispatch'", + "github.event.inputs.build == 'true'", + "matrix.build == true", + ]) + expect( + prove( + [ + { ...checkout, if: predicate }, + { ...consumer, if: predicate }, + ], + { ...declaration, condition: predicate }, + ), + ).toBe(true); +}); + test("every changed source field fails closed", () => { for (const changed of [ { ...checkout, id: "other" }, diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.ts b/packages/oxlint-config/src/toolchain-checkout-sources.ts index 933c7196..894983ca 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.ts @@ -101,6 +101,45 @@ type CheckoutSourceProof = { repository?: string | undefined; }; +/** Identical predicates are sufficient only when their operands cannot change between steps. */ +const stableCheckoutCondition = ( + condition: string, + steps: unknown[], + checkoutIndex: number, +) => { + const expression = condition.trim().replace(/^\$\{\{([\s\S]*)\}\}$/, "$1"); + const tokens = + expression.match( + /'(?:[^']|'')*'|[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*|\d+|&&|\|\||==|!=|[!()<>]|\s+|./g, + ) ?? []; + return tokens.every((token) => { + if ( + /^(?:\s+|'(?:[^']|'')*'|\d+|true|false|null|&&|\|\||==|!=|[!()<>])$/.test( + token, + ) + ) + return true; + if (/^(?:inputs|needs|matrix|strategy)\.[A-Za-z_][\w.-]*$/.test(token)) + return true; + if ( + /^github\.(?:event_name|repository|ref|sha|workflow_sha)$/.test(token) || + /^github\.event\.[A-Za-z_][\w.-]*$/.test(token) + ) + return true; + const referenced = + /^steps\.([A-Za-z_][\w-]*)\.(?:outputs\.[A-Za-z_][\w.-]*|outcome|conclusion)$/ + .exec(token) + ?.at(1); + if (referenced === undefined) return false; + const indices = steps.flatMap((step, index) => + record(step) && step["id"] === referenced ? [index] : [], + ); + return ( + indices.length === 1 && (indices.at(0) ?? checkoutIndex) < checkoutIndex + ); + }); +}; + /** Resolve the last overlapping writer before this consumer; later writers cannot alter its input. */ export const proveCheckoutSource = ({ declaration, @@ -143,7 +182,7 @@ export const proveCheckoutSource = ({ Object.hasOwn(checkout, "continue-on-error") ) return false; - const options = checkout["with"]; + const options = checkout["with"] === undefined ? {} : checkout["with"]; if ( !record(options) || Object.keys(options).some((key) => key !== key.toLowerCase()) @@ -164,6 +203,11 @@ export const proveCheckoutSource = ({ options["repository"] !== repository) ) return false; + if ( + declaration.condition !== null && + !stableCheckoutCondition(declaration.condition, steps, checkoutIndex) + ) + return false; const selected = path.posix.normalize(selector); const prefix = path.posix.normalize(declaration.destination); if (prefix !== "." && !selected.startsWith(`${prefix}/`)) return false; From 4087947e57d7898f4d35c067c833133f32802417 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 28 Oct 2026 08:43:21 +0100 Subject: [PATCH 5/6] fix: preserve checkout success and path overlap proofs --- packages/oxlint-config/README.md | 3 ++- .../src/toolchain-checkout-sources.test.ts | 23 +++++++++++++++++++ .../src/toolchain-checkout-sources.ts | 15 +++++++++--- .../src/toolchain-checkout-syntax.ts | 3 +++ .../oxlint-config/src/toolchain-runtime.ts | 3 +-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index a034ec1c..cb36f31c 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -279,7 +279,8 @@ Both step IDs must be unique in the named job. The reference, destination and checkout condition must match exactly; a conditional checkout requires the consumer to have the identical condition with immutable operands. Step-output references must name a unique producer before the checkout; mutable contexts, -status functions and dynamic property access are rejected. The selected checkout must precede +status functions and dynamic property access are rejected. Consumers of an +unconditional checkout also cannot override its implicit success requirement. The selected checkout must precede the consumer, use the approved checkout pin and the same repository, retain cleaning, and omit `continue-on-error`. A later overlapping or unknown checkout destination before the consumer invalidates the proof. An earlier writer is diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts index 8ae71121..9c9c4cb8 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts @@ -77,6 +77,26 @@ test("exact reviewed selection permits historical source and sequential replacem ).toBe(true); }); +test("consumers cannot override the selected checkout success requirement", () => { + for (const condition of [ + "always()", + "failure()", + "cancelled()", + "${{ !cancelled() }}", + ]) + expect( + prove([ + { ...checkout, id: "old", with: { ref: "main" } }, + { run: "exit 1" }, + checkout, + { ...consumer, if: condition }, + ]), + ).toBe(false); + expect( + prove([checkout, { ...consumer, if: "github.event_name == 'push'" }]), + ).toBe(true); +}); + test("conditional checkout proofs require immutable operands available before checkout", () => { const condition = "steps.resolve.outputs.build == 'true'"; const declared = { ...declaration, condition }; @@ -169,7 +189,10 @@ test("intervening overlapping or unknown writers cannot be waived", () => { ).toBe(false); for (const destination of [ ".", + "./", ".github", + ".github/", + "./.github//", ".github/actions/build", ".github/actions/build/nested", "${{ inputs.path }}", diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.ts b/packages/oxlint-config/src/toolchain-checkout-sources.ts index 894983ca..5359d1c7 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { checkoutActionRef, literalSparseCheckoutPath, + normalizeCheckoutPath, } from "./toolchain-checkout-syntax"; export type CheckoutSource = { @@ -208,8 +209,16 @@ export const proveCheckoutSource = ({ !stableCheckoutCondition(declaration.condition, steps, checkoutIndex) ) return false; - const selected = path.posix.normalize(selector); - const prefix = path.posix.normalize(declaration.destination); + const consumerCondition = consumer["if"]; + if ( + declaration.condition === null && + consumerCondition !== undefined && + (typeof consumerCondition !== "string" || + !stableCheckoutCondition(consumerCondition, steps, consumerIndex)) + ) + return false; + const selected = normalizeCheckoutPath(selector); + const prefix = normalizeCheckoutPath(declaration.destination); if (prefix !== "." && !selected.startsWith(`${prefix}/`)) return false; for (const step of steps.slice(checkoutIndex + 1, consumerIndex)) { if (!record(step) || checkoutActionRef(step["uses"]) === undefined) @@ -218,7 +227,7 @@ export const proveCheckoutSource = ({ const other = record(withOptions) ? (withOptions["path"] ?? ".") : "."; // Unknown destinations can shadow any selector, even when conditionally written. if (!repositoryPath(other)) return false; - const otherPrefix = path.posix.normalize(other); + const otherPrefix = normalizeCheckoutPath(other); if ( otherPrefix === "." || selected === otherPrefix || diff --git a/packages/oxlint-config/src/toolchain-checkout-syntax.ts b/packages/oxlint-config/src/toolchain-checkout-syntax.ts index 0e7d7e6c..82db8cb0 100644 --- a/packages/oxlint-config/src/toolchain-checkout-syntax.ts +++ b/packages/oxlint-config/src/toolchain-checkout-syntax.ts @@ -11,3 +11,6 @@ export const literalSparseCheckoutPath = (value: string) => !/[!$*?[\]\\:]/.test(value) && !path.posix.isAbsolute(value) && !value.split("/").includes(".."); + +export const normalizeCheckoutPath = (prefix: string) => + path.posix.normalize(prefix).replace(/\/$/, "") || "."; diff --git a/packages/oxlint-config/src/toolchain-runtime.ts b/packages/oxlint-config/src/toolchain-runtime.ts index 42c328c5..edf200d5 100644 --- a/packages/oxlint-config/src/toolchain-runtime.ts +++ b/packages/oxlint-config/src/toolchain-runtime.ts @@ -17,6 +17,7 @@ import { import { checkoutActionRef, literalSparseCheckoutPath, + normalizeCheckoutPath, } from "./toolchain-checkout-syntax"; import { containerDocumentImages, @@ -1111,8 +1112,6 @@ export const checkRuntimeFile = ({ !value.includes(":") && !path.posix.isAbsolute(value) && !value.split("/").includes(".."); - const normalizeCheckoutPath = (prefix: string) => - path.posix.normalize(prefix).replace(/\/$/, "") || "."; type RuntimeReferenceOptions = { value: unknown; tool: "node" | "python" | "bun"; From 684c84d998fc9d2a9a51f66c5f46813508b4b09d Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 28 Oct 2026 08:54:06 +0100 Subject: [PATCH 6/6] fix: share sparse paths and repository identity semantics --- .../src/toolchain-checkout-sources.test.ts | 43 +++++++++++++++++++ .../src/toolchain-checkout-sources.ts | 31 +++++++++---- .../src/toolchain-checkout-syntax.ts | 16 +++++++ .../oxlint-config/src/toolchain-runtime.ts | 14 ++---- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts index 9c9c4cb8..5fd4f024 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.test.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.test.ts @@ -314,3 +314,46 @@ test("runtime integration retains undeclared and changed-writer failures", () => ), ).toBe(true); }); + +test("directory-only sparse patterns never prove a version file", () => { + const selector = ".node-version"; + const entry = { ...declaration, selector }; + for (const cone of [false, "false", true, "true"]) { + expect( + proveCheckoutSource({ + declaration: entry, + checkoutSha: sha, + selector, + workflow: workflow([ + { + ...checkout, + with: { + ...checkout.with, + "sparse-checkout": `${selector}/`, + "sparse-checkout-cone-mode": cone, + }, + }, + consumer, + ]), + }), + ).toBe(false); + } + for (const repository of [ + "stella/tooling", + "Stella/Tooling", + "STELLA/TOOLING", + ]) { + expect( + proveCheckoutSource({ + declaration, + checkoutSha: sha, + selector: declaration.selector, + repository: "stella/tooling", + workflow: workflow([ + { ...checkout, with: { ...checkout.with, repository } }, + consumer, + ]), + }), + ).toBe(true); + } +}); diff --git a/packages/oxlint-config/src/toolchain-checkout-sources.ts b/packages/oxlint-config/src/toolchain-checkout-sources.ts index 5359d1c7..ae8acc48 100644 --- a/packages/oxlint-config/src/toolchain-checkout-sources.ts +++ b/packages/oxlint-config/src/toolchain-checkout-sources.ts @@ -4,6 +4,8 @@ import { checkoutActionRef, literalSparseCheckoutPath, normalizeCheckoutPath, + selfCheckoutRepository, + sparseCheckoutPaths, } from "./toolchain-checkout-syntax"; export type CheckoutSource = { @@ -199,9 +201,7 @@ export const proveCheckoutSource = ({ (options["clean"] !== undefined && options["clean"] !== true && options["clean"] !== "true") || - (options["repository"] !== undefined && - options["repository"] !== "${{ github.repository }}" && - options["repository"] !== repository) + !selfCheckoutRepository(options["repository"], repository) ) return false; if ( @@ -236,13 +236,26 @@ export const proveCheckoutSource = ({ ) return false; } + const cone = options["sparse-checkout-cone-mode"]; + if ( + Object.keys(options).some( + (key) => + key.startsWith("sparse-checkout") && + key !== "sparse-checkout" && + key !== "sparse-checkout-cone-mode", + ) || + (cone !== undefined && + ((cone !== true && + cone !== false && + cone !== "true" && + cone !== "false") || + options["sparse-checkout"] === undefined)) + ) + return false; const sparse = options["sparse-checkout"]; if (sparse !== undefined) { if (typeof sparse !== "string") return false; - const patterns = sparse - .split(/\r?\n/) - .map((value) => value.trim().replace(/^\//, "").replace(/\/$/, "")) - .filter(Boolean); + const patterns = sparseCheckoutPaths(sparse); if ( patterns.length === 0 || patterns.some((value) => !literalSparseCheckoutPath(value)) @@ -252,7 +265,9 @@ export const proveCheckoutSource = ({ prefix === "." ? selected : path.posix.relative(prefix, selected); if ( !patterns.some( - (value) => relative === value || relative.startsWith(`${value}/`), + (value) => + relative === value || + relative.startsWith(value.endsWith("/") ? value : `${value}/`), ) ) return false; diff --git a/packages/oxlint-config/src/toolchain-checkout-syntax.ts b/packages/oxlint-config/src/toolchain-checkout-syntax.ts index 82db8cb0..290ab89b 100644 --- a/packages/oxlint-config/src/toolchain-checkout-syntax.ts +++ b/packages/oxlint-config/src/toolchain-checkout-syntax.ts @@ -14,3 +14,19 @@ export const literalSparseCheckoutPath = (value: string) => export const normalizeCheckoutPath = (prefix: string) => path.posix.normalize(prefix).replace(/\/$/, "") || "."; + +export const selfCheckoutRepository = ( + value: unknown, + repository: string | undefined, +) => + value === undefined || + value === "${{ github.repository }}" || + (typeof value === "string" && + repository !== undefined && + value.toLowerCase() === repository.toLowerCase()); + +export const sparseCheckoutPaths = (value: string) => + value + .split(/\r?\n/) + .map((entry) => entry.trim().replace(/^\//, "")) + .filter((entry) => entry !== ""); diff --git a/packages/oxlint-config/src/toolchain-runtime.ts b/packages/oxlint-config/src/toolchain-runtime.ts index edf200d5..81619c7e 100644 --- a/packages/oxlint-config/src/toolchain-runtime.ts +++ b/packages/oxlint-config/src/toolchain-runtime.ts @@ -18,6 +18,8 @@ import { checkoutActionRef, literalSparseCheckoutPath, normalizeCheckoutPath, + selfCheckoutRepository, + sparseCheckoutPaths, } from "./toolchain-checkout-syntax"; import { containerDocumentImages, @@ -1530,10 +1532,7 @@ export const checkRuntimeFile = ({ cone.value !== "false"))) ) return { mode: "invalid" }; - const paths = value.value - .split(/\r?\n/) - .map((entry) => entry.trim().replace(/^\//, "")) - .filter((entry) => entry !== ""); + const paths = sparseCheckoutPaths(value.value); if (paths.length === 0 || paths.some((entry) => !sparseEntryIsValid(entry))) return { mode: "invalid" }; const literalPaths = new Set(paths.filter((entry) => !dynamic(entry))); @@ -1680,12 +1679,7 @@ export const checkRuntimeFile = ({ let repo: unknown; if (checkoutRepository !== undefined) repo = isScalar(checkoutRepository) ? checkoutRepository.value : null; - const self = - repo === undefined || - repo === "${{ github.repository }}" || - (typeof repo === "string" && - repository !== undefined && - repo.toLowerCase() === repository.toLowerCase()); + const self = selfCheckoutRepository(repo, repository); const workflowSource = repo === "${{ job.workflow_repository }}" && isScalar(checkoutRef) &&