diff --git a/.github/scripts/gen-action-docs.ts b/.github/scripts/gen-action-docs.ts index 24fdc12c..94e8880b 100644 --- a/.github/scripts/gen-action-docs.ts +++ b/.github/scripts/gen-action-docs.ts @@ -1,5 +1,5 @@ import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, posix } from "node:path"; +import { join } from "node:path"; import { OUTPUT_DECLS } from "../../src/action/io.js"; import type { InputDecl } from "../../src/flows/inputs.js"; import { INPUT_DECLS } from "../../src/flows/inputs.js"; @@ -102,39 +102,6 @@ function row(cells: readonly string[]): string { return `| ${cells.map(cell).join(" | ")} |`; } -function shownDefault(decl: Pick): string { - if (decl.shownDefault !== undefined) { - return decl.shownDefault; - } - return decl.default === "" ? "(empty)" : `\`${decl.default}\``; -} - -const INPUTS_TABLE_HEADER = "| Input | Default | Meaning |\n|---|---|---|"; - -/** A link target that is not a repository path: a URI scheme (any case), protocol-relative, or site-absolute. */ -const ABSOLUTE_TARGET = /^(?:[a-z][a-z0-9+.-]*:|\/)/i; - -/** Link targets are written root-relative in the declarations and rebased onto `pageDir`, so one summary reads right - * from every page the table renders on. */ -function rebaseLinks(text: string, pageDir: string): string { - return text.replace(/\]\(([^)#]+)(#[^)]*)?\)/g, (match, target: string, fragment: string = "") => - ABSOLUTE_TARGET.test(target) ? match : `](${posix.relative(pageDir, target)}${fragment})`, - ); -} - -/** `pageDir` is "." for the repository root. */ -export function renderInputsTable( - decls: Readonly>>, - pageDir: string, -): string { - return [ - INPUTS_TABLE_HEADER, - ...Object.entries(decls).map(([name, decl]) => - row([`\`${name}\``, shownDefault(decl), rebaseLinks(decl.summary, pageDir)]), - ), - ].join("\n"); -} - function proseList(items: readonly string[]): string { if (items.length <= 2) { return items.join(" and "); @@ -363,17 +330,6 @@ function block(render: () => string): () => string { return () => `\n${render()}\n`; } -function inputsTableRegion(name: string, heading: string, path: string): GeneratedRegion { - return { - name, - placement: { kind: "under-heading", heading }, - body: tableShape(INPUTS_TABLE_HEADER, String.raw`\x60[^\x60\n]+\x60 \| [^\n]* \| [^\n]*`), - render: block(() => renderInputsTable(INPUT_DECLS, dirname(path))), - }; -} - -const INPUTS_PAGE_PATH = "docs/reference/inputs.md"; - /** Each region's `body` matches every body this generator could have written for it, so a marker moved elsewhere * fails instead of regenerating in the wrong place or erasing authored text. */ export const GENERATED_REGIONS: Readonly> = { @@ -393,7 +349,6 @@ export const GENERATED_REGIONS: Readonly renderActionOutputs(OUTPUT_DECLS)), }, ], - [INPUTS_PAGE_PATH]: [inputsTableRegion("inputs-table", "## Inputs", INPUTS_PAGE_PATH)], "docs/reference/undeclared-policy.md": [ { name: "policy-count-sentence", diff --git a/.github/scripts/gen-inputs-table.ts b/.github/scripts/gen-inputs-table.ts new file mode 100644 index 00000000..a5471ba9 --- /dev/null +++ b/.github/scripts/gen-inputs-table.ts @@ -0,0 +1,121 @@ +/** Renders the inputs table on docs/reference/inputs.md with action-docs, after refusing a marker region holding anything + * but the lines of a rendering in the renderer's order: action-docs rewrites the region blind, and auto-fix.yml pushes + * regenerations unreviewed, so an authored line that merely looks like a table row would be erased and committed. */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { generateActionMarkdownDocs } from "action-docs"; + +const ROOT = join(import.meta.dir, "..", ".."); + +/** The page action-docs renders the inputs table into, from the action.yml build:action-docs writes just before. */ +export const INPUTS_PAGE_PATH = "docs/reference/inputs.md"; + +export const MARKER = ''; + +/** The header row action-docs emits; test/docs/inputs.test.ts pins the same cells on the committed page. */ +const HEADER_CELLS = ["name", "description", "required", "default"]; + +/** The trimmed cells of a table line `| a | b |`, or null for any other line. One split per line and no regex: the + * earlier four-cell regex backtracked exponentially on a long row of repeated cells. */ +const cells = (line: string): string[] | null => + line.startsWith("| ") && line.endsWith(" |") + ? line + .slice(1, -1) + .split("|") + .map((cell) => cell.trim()) + : null; + +type CellCheck = (cell: string) => boolean; + +const lineOf = + (checks: CellCheck[]) => + (line: string): boolean => { + const found = cells(line); + return ( + found !== null && + found.length === checks.length && + checks.every((ok, i) => ok(found[i] ?? "")) + ); + }; + +const backticked: CellCheck = (cell) => + cell.length > 2 && cell.startsWith("`") && cell.endsWith("`"); + +/** A row action-docs renders: | `name` |

description

| `false` | `default` |. An authored four-cell row + * with bare cells is refused on these, since a cell count alone let one through. */ +const row = lineOf([ + backticked, + (cell) => cell.startsWith("

") && cell.endsWith("

"), + (cell) => cell === "`true`" || cell === "`false`", + backticked, +]); + +/** What action-docs writes between its markers, by position after the opening marker's own line ends: + * + * "" the rest of the marker line + * "## Inputs" + * "" + * "| name | description | required | default |" + * "| --- | --- | --- | --- |" + * one row per input a description never holds a "|" (test/docs/inputs.test.ts) + * "" before the closing marker + * + * A region of blank lines alone (a fresh marker pair) is also a rendering's shape. */ +const LEADING: ((line: string) => boolean)[] = [ + (line) => line === "", + (line) => line === "## Inputs", + (line) => line === "", + lineOf(HEADER_CELLS.map((name) => (cell: string) => cell === name)), + lineOf(HEADER_CELLS.map(() => (cell: string) => cell === "---")), +]; + +/** Why `page` cannot be handed to action-docs, or null when its region holds only a rendering's lines in order. */ +export function regionProblem(page: string): string | null { + const parts = page.split(MARKER); + if (parts.length !== 3) { + return `${INPUTS_PAGE_PATH} must carry exactly two "${MARKER}" markers, found ${parts.length - 1}`; + } + const [intro, region] = parts as [string, string, string]; + const openingLine = intro.split("\n").length; + const lines = region.split("\n"); + const last = lines.length - 1; + if (last === 0) { + // action-docs then replaces the opening marker alone and leaves three on the page. + return `the closing "${MARKER}" marker on line ${openingLine} of ${INPUTS_PAGE_PATH} must start its own line`; + } + if (lines.every((line) => line === "")) { + return null; + } + const authored = lines.findIndex((line, index) => { + const leading = LEADING[index]; + if (leading !== undefined) { + return !leading(line); + } + return index === last ? line !== "" : !row(line); + }); + if (authored !== -1) { + return ( + `line ${openingLine + authored} of ${INPUTS_PAGE_PATH} sits between the two "${MARKER}" markers ` + + "but is not a line of a rendered inputs table; move the markers back around the table before " + + "regenerating, or action-docs erases what sits between them" + ); + } + return null; +} + +if (import.meta.main) { + // action-docs resolves both files from the working directory and matches the marker on the literal source name. + process.chdir(ROOT); + const problem = regionProblem(readFileSync(INPUTS_PAGE_PATH, "utf8")); + if (problem !== null) { + console.error(`gen-inputs-table: ${problem}`); + process.exit(1); + } + await generateActionMarkdownDocs({ + sourceFile: "action.yml", + updateReadme: true, + readmeFile: INPUTS_PAGE_PATH, + }); + console.log(`gen-inputs-table: rendered ${INPUTS_PAGE_PATH} from action.yml`); +} diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts index 0af850b7..26d63e14 100644 --- a/.github/scripts/generated.ts +++ b/.github/scripts/generated.ts @@ -1,21 +1,22 @@ /** * The one table of committed generated output, derived from the generators' own registries, and the drift check - * behind `bun run build:check`: every generator runs, then every registered path must be tracked and unchanged. + * behind `bun run build:check`: every generator script runs, then every registered path must be tracked and unchanged. */ import { join } from "node:path"; import { GENERATED_REGIONS } from "./gen-action-docs.js"; import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js"; import { INDEX_PATH } from "./gen-gaps-index.js"; +import { INPUTS_PAGE_PATH } from "./gen-inputs-table.js"; const ROOT = join(import.meta.dir, "..", ".."); export interface GeneratedOutput { /** The committed output, repo-relative. */ readonly path: string; - /** The script that writes it, repo-relative; `bun ` regenerates it in place. */ + /** The package.json script that writes it; `bun run ` regenerates it in place. */ readonly generator: string; - /** Marker-delimited regions inside an authored file (lib/generated-regions.ts), or the whole file. */ + /** Marker-delimited regions inside an authored file (lib/generated-regions.ts, or action-docs's own markers), or the whole file. */ readonly kind: "regions" | "file"; } @@ -23,16 +24,14 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[] return paths.map((path) => ({ path, generator, kind: "regions" })); } -/** A page two generators write into (docs/reference/inputs.md) has one row per generator. Table order is run order. */ +/** A page two generators write into (docs/reference/inputs.md) has one row per generator. Table order is run order: + * the schema, docs, and action.yml generators import the gaps index through src/, and action.yml feeds the inputs table, so each renders first, or a new gap file or a bump would leave a run stale. */ export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ - { - path: "lib/settings.schema.json", - generator: ".github/scripts/gen-settings-schema.ts", - kind: "file", - }, - ...regions(".github/scripts/gen-docs.ts", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), - ...regions(".github/scripts/gen-action-docs.ts", Object.keys(GENERATED_REGIONS)), - { path: INDEX_PATH, generator: ".github/scripts/gen-gaps-index.ts", kind: "file" }, + { path: INDEX_PATH, generator: "build:gaps-index", kind: "file" }, + { path: "lib/settings.schema.json", generator: "build:schema", kind: "file" }, + ...regions("build:docs", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), + ...regions("build:action-docs", Object.keys(GENERATED_REGIONS)), + { path: INPUTS_PAGE_PATH, generator: "build:inputs-table", kind: "regions" }, ]; /** The distinct paths, in table order. */ @@ -40,15 +39,26 @@ export function generatedPaths(): string[] { return [...new Set(GENERATED_OUTPUTS.map((output) => output.path))]; } +/** The distinct generator scripts, in run order. */ +export function generatorScripts(): string[] { + return [...new Set(GENERATED_OUTPUTS.map((output) => output.generator))]; +} + +/** The repository file a generator script runs (`bun .ts`), or null for any other shape, which the tests + * refuse rather than drop from their census. */ +export function generatorEntryPoint(script: string): string | null { + return /^bun (\S+\.ts)$/.exec(script)?.[1] ?? null; +} + /** Runs `argv` at the repository root on the terminal's stdio; the exit code is the verdict. */ function run(argv: string[]): number { return Bun.spawnSync(argv, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }).exitCode; } if (import.meta.main) { - for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { - if (run([process.execPath, generator]) !== 0) { - console.error(`build:check: ${generator} failed`); + for (const generator of generatorScripts()) { + if (run([process.execPath, "run", generator]) !== 0) { + console.error(`build:check: bun run ${generator} failed`); process.exit(1); } } @@ -67,7 +77,7 @@ if (import.meta.main) { if (drifted || untracked.stdout.length > 0) { process.stdout.write(untracked.stdout); console.error( - "build:check: generated output drifted from the committed tree; commit the files listed above", + "build:check: the generated output listed above drifted from the committed tree; run bun run build and commit it", ); process.exit(1); } diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index cbf107dc..77f7fd22 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -1,7 +1,8 @@ # The commit-back fixes a same-repo PR can need, pushed to its branch; an already-clean tree gets no commit. A PR # with unrelated type errors fails the build job by design: the graduation script refuses to half-fix a red build. # lib/settings.schema.json -> build:schema (a Dependabot generator bump changes its bytes on a branch nobody builds) -# README, action.yml, docs/ regions -> build:docs, build:action-docs +# README, action.yml, docs/ regions -> build:docs, build:action-docs, build:inputs-table (action-docs renders +# the inputs table from action.yml) # src/upstream-gaps/ -> graduate-upstream-gaps.ts retires the gap files @octokit/types caught up with; # gen-gaps-index.ts re-renders index.ts # lib/index.js -> never: main carries no bundle @@ -36,6 +37,7 @@ on: - "docs/operate/check-mode.md" - "docs/start/getting-started.md" - ".github/scripts/gen-action-docs.ts" + - ".github/scripts/gen-inputs-table.ts" - "docs/reference/coverage.md" - "package.json" - "bun.lock" @@ -80,16 +82,18 @@ jobs: - name: Graduate upstream gaps octokit now ships shell: bash run: bun .github/scripts/graduate-upstream-gaps.ts - - name: Regenerate the schema, docs, and gaps index and stage the fix patch + - name: Regenerate the gaps index, schema, docs, and inputs table and stage the fix patch id: rebuild shell: bash run: | - # One line per generator, in .github/scripts/generated.ts table order; test/scripts/auto-fix-allowlist.test.ts - # pins the list and the order. + # One line per generator, in .github/scripts/generated.ts table order: the later generators import the + # gaps index through src/, and the inputs table reads action.yml, so each source renders first. + # test/scripts/auto-fix-allowlist.test.ts pins the list and the order. + bun run build:gaps-index bun run build:schema bun run build:docs bun run build:action-docs - bun .github/scripts/gen-gaps-index.ts + bun run build:inputs-table # Anything the earlier steps left staged is not this workflow's fix: start from an empty index so the # patch holds exactly the allowed paths. git reset -q diff --git a/action.yml b/action.yml index 48cfc6aa..ab9db855 100644 --- a/action.yml +++ b/action.yml @@ -77,13 +77,13 @@ inputs: description: >- mode: snapshot only, and exactly one of snapshot-file and snapshot-dir is required there: the directory the multi-repo targets' live settings - are written under, one /.yml per target (the repos-dir - layout, so the directory can later serve as a repos-dir). The targets - come from repos and repos-dir exactly as in a multi-repo apply, - discovery filters included; defaults-file does not apply. Must be - disjoint from the repos-dir (not the same directory, not above it, not - below it): the snapshots would overwrite the central files or be read - back as central files. Fails when set in apply, check, or render. + are written under, one owner/name.yml per target (the repos-dir layout, + so the directory can later serve as a repos-dir). The targets come from + repos and repos-dir exactly as in a multi-repo apply, discovery filters + included; defaults-file does not apply. Must be disjoint from the + repos-dir (not the same directory, not above it, not below it): the + snapshots would overwrite the central files or be read back as central + files. Fails when set in apply, check, or render. required: false default: "" on-missing-permission: @@ -103,10 +103,11 @@ inputs: default: "" sections: description: >- - Optional comma-separated allowlist of sections to process. apply, check, - and snapshot only: mode: render writes every section its layers declare, - so the allowlist belongs on the step that runs the rendered document and - fails the render when set. + Optional comma-separated allowlist of sections to process; unset, every + declared section is processed. apply, check, and snapshot only: mode: + render writes every section its layers declare, so the allowlist belongs + on the step that runs the rendered document and fails the render when + set. required: false default: "" api-version: @@ -128,8 +129,8 @@ inputs: repos-dir: description: >- Multi-repo central mode: a directory in the checked-out admin repository - holding per-repo settings files - .yml (same owner as this - repository) or /.yml. Requires actions/checkout. + holding per-repo settings files - name.yml (same owner as this + repository) or owner/name.yml. Requires actions/checkout. required: false default: "" defaults-file: @@ -148,10 +149,10 @@ inputs: mode: render only: replace, shallow, or deep (default), the run-wide default for how every list section's entries combine with the layers below them, each section by its own key (a label's name, a ruleset's - name, a secret's name, ...). replace lets the higher list win wholesale; - shallow unions the entries by key and swaps a same-key entry for the - higher one; deep unions by key and merges a same-key pair field by - field, a nested keyed list (a ruleset's rules, by type) unioning the + name, a secret's name, and so on). replace lets the higher list win + wholesale; shallow unions the entries by key and swaps a same-key entry + for the higher one; deep unions by key and merges a same-key pair field + by field, a nested keyed list (a ruleset's rules, by type) unioning the same way. A layer's own _layering directive, at its top level or on a section's {entries} wrapper, overrides it per file or per section. Lists outside the list sections are replaced by the higher layer's. Fails when @@ -211,7 +212,7 @@ inputs: default: "none" report-public-key: description: >- - The age recipient (an "age1..." public key) the artifact channel + The age recipient (a public key starting with age1) the artifact channel encrypts every report to; safe to commit in the workflow. Generate a keypair with "age-keygen -o key.txt", keep key.txt secret, and decrypt a downloaded artifact with "age -d -i key.txt private-report.md.age". @@ -222,44 +223,45 @@ inputs: description: >- Keeps only repositories of this visibility in repos: "*" discovery. One of all (default), public, private, or internal; internal is matched - client-side (Enterprise only). Fails if set without repos: "*". + client-side (Enterprise only). Fails if set outside that discovery. required: false default: "" archived: description: >- Archived-repository policy for repos: "*" discovery. One of skip (default; settings writes fail on archived repositories), include, or - only (mostly useful with mode: check). Fails if set without repos: "*". + only (mostly useful with mode: check). Fails if set outside that + discovery. required: false default: "" forks: description: >- Fork policy for repos: "*" discovery. One of include (default), exclude, - or only. Fails if set without repos: "*". + or only. Fails if set outside that discovery. required: false default: "" exclude: description: >- Comma- or newline-separated wildcard patterns removing repositories from - repos: "*" discovery. "*" matches any characters; a pattern containing - "/" matches the full owner/name, any other the name alone. - Case-insensitive. Fails if set without repos: "*". + repos: "*" discovery. An asterisk matches any characters; a pattern + containing "/" matches the full owner/name, any other the name alone. + Case-insensitive. Fails if set outside that discovery. required: false default: "" topics: description: >- Comma- or newline-separated topics; repos: "*" discovery keeps only repositories carrying at least one of them. Unrelated to the topics - settings section. Fails if set without repos: "*". + settings section. Fails if set outside that discovery. required: false default: "" affiliation: description: >- Comma-separated affiliations for repos: "*" discovery, passed to the - GitHub /user/repos listing. Any of owner, collaborator, - organization_member; the list replaces the default (owner), so use + GitHub /user/repos listing. Any of owner (default), collaborator, + organization_member; the list replaces the default, so use owner,collaborator to widen rather than move discovery. Fails if set - without repos: "*". + outside that discovery. required: false default: "" # END GENERATED: action-inputs diff --git a/bun.lock b/bun.lock index 14973795..9c43e8ba 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@octokit/openapi-webhooks": "12.1.0", "@types/bun": "1.4.0", "@types/node": "26.4.1", + "action-docs": "2.5.1", "ajv": "8.20.0", "ajv-formats": "3.0.1", "entities": "8.1.0", @@ -404,6 +405,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "action-docs": ["action-docs@2.5.1", "", { "dependencies": { "chalk": "^5.3.0", "figlet": "^1.7.0", "replace-in-file": "^7.1.0", "showdown": "^2.1.0", "yaml": "^2.3.4", "yargs": "^17.7.2" }, "bin": { "action-docs": "lib/cli.js" } }, "sha512-kACC20UOsuVifAEYZAAMsm+Lpq14nWXM3FDbIUqUiu7s3KtlGSfRG5btboYIGNomZQ5coTc/UR1F5H9yRqTAEw=="], + "age-encryption": ["age-encryption@0.3.1", "", { "dependencies": { "@noble/ciphers": "^2.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@noble/post-quantum": "^0.5.3", "@scure/base": "^2.0.0" } }, "sha512-bYgd7lxM7tEANmb9bXf7xTFB3Qpq+JGKinVSdFh5MV+t2fJJZSTdKhWkvXTFJQGysZd+K9Dt3F+n+4DKazXlnQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -474,7 +477,7 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -538,15 +541,19 @@ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "figlet": ["figlet@1.11.4", "", { "dependencies": { "commander": "^14.0.0" }, "bin": { "figlet": "bin/index.js" } }, "sha512-ZU41480OncL+NVLQrT0GVnbO0COL24sLSrzksioDgunmdJNYEUxhJJZ4Y3x1csN8XXqN5OcCZTLG8lKdquNyfQ=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "formatly": ["formatly@0.7.0", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -568,6 +575,8 @@ "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -652,6 +661,8 @@ "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxc-parser": ["oxc-parser@0.147.0", "", { "dependencies": { "@oxc-project/types": "^0.147.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.147.0", "@oxc-parser/binding-android-arm64": "0.147.0", "@oxc-parser/binding-darwin-arm64": "0.147.0", "@oxc-parser/binding-darwin-x64": "0.147.0", "@oxc-parser/binding-freebsd-x64": "0.147.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.147.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.147.0", "@oxc-parser/binding-linux-arm64-gnu": "0.147.0", "@oxc-parser/binding-linux-arm64-musl": "0.147.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.147.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.147.0", "@oxc-parser/binding-linux-riscv64-musl": "0.147.0", "@oxc-parser/binding-linux-s390x-gnu": "0.147.0", "@oxc-parser/binding-linux-x64-gnu": "0.147.0", "@oxc-parser/binding-linux-x64-musl": "0.147.0", "@oxc-parser/binding-openharmony-arm64": "0.147.0", "@oxc-parser/binding-win32-arm64-msvc": "0.147.0", "@oxc-parser/binding-win32-ia32-msvc": "0.147.0", "@oxc-parser/binding-win32-x64-msvc": "0.147.0" } }, "sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA=="], "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], @@ -686,6 +697,8 @@ "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + "replace-in-file": ["replace-in-file@7.2.0", "", { "dependencies": { "chalk": "^4.1.2", "glob": "^8.1.0", "yargs": "^17.7.2" }, "bin": { "replace-in-file": "bin/cli.js" } }, "sha512-CiLXVop3o8/h2Kd1PwKPPimmS9wUV0Ki6Fl8+1ITD35nB3Gl/PrW5IONpTE0AXk0z4v8WYcpEpdeZqMXvSnWpg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -706,6 +719,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "showdown": ["showdown@2.1.0", "", { "dependencies": { "commander": "^9.0.0" }, "bin": { "showdown": "bin/showdown.js" } }, "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], @@ -786,15 +801,17 @@ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], - "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yuku-ast": ["yuku-ast@0.9.5", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.5" } }, "sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ=="], @@ -836,7 +853,13 @@ "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "action-docs/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], + + "figlet/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -850,6 +873,8 @@ "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], + "showdown/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -866,6 +891,12 @@ "@octokit/plugin-throttling/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + "archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/docs/README.md b/docs/README.md index 8763b633..78920e66 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,9 +69,10 @@ The [playbooks](playbooks/README.md) compose the pieces above into end-to-end se ## Where the facts live -Generated regions carry the load-bearing facts. Each is rendered from its declarations or generator data by `bun run build:docs` and `bun run build:action-docs`, and `build:check` fails when a committed page drifts: +Generated regions carry the load-bearing facts. Each is rendered from its declarations or generator data by `bun run build:docs`, `bun run build:action-docs`, and `bun run build:inputs-table`, and `build:check` fails when a committed page drifts: -- the [Sections](reference/sections.md) and [Inputs](reference/inputs.md) tables, and the `result` values on the inputs page; +- the [Sections](reference/sections.md) table and the `result` values on the inputs page; +- the [Inputs](reference/inputs.md) table, which [action-docs](https://github.com/npalm/action-docs) renders from `action.yml`; - the [Coverage](reference/coverage.md) tables and notes, the per-section detail behind the Sections table; - the defaults table and count in [undeclared policy](reference/undeclared-policy.md); - the grant sentence and gated-read bullets in [permissions](reference/permissions.md) and [check mode](operate/check-mode.md); diff --git a/docs/reference/inputs.md b/docs/reference/inputs.md index 2ac995d7..bd4c66a7 100644 --- a/docs/reference/inputs.md +++ b/docs/reference/inputs.md @@ -4,39 +4,44 @@ order: 120 # Inputs and outputs -Every `with:` input the action accepts, and the outputs it sets for the steps after it. Every input is optional; the Default column is what an omitted input means. +Every `with:` input the action accepts, and the outputs it sets for the steps after it. Every input is optional (the required column is always `false`); a default of `""` is an unset input, and the description says what an omitted input means. The `token` grant is on the [permissions](permissions.md) page, the `undeclared` policies on the [undeclared policy](undeclared-policy.md) page. + +
+ + ## Inputs - -| Input | Default | Meaning | -|---|---|---| -| `token` | `github.token` | Token for the API calls (see [Token permissions](permissions.md)) | -| `repository` | current repo | Target `owner/name` (single-repo mode only) | -| `settings-file` | `.github/settings.yml` | Settings file path (single-repo mode); in `mode: render`, the ordered list of layers to fold, low to high | -| `mode` | `apply` | `apply` mutates; `check` reports drift and exits 1 on any, making no settings changes (a private report may still be delivered); `render` folds the settings-file layers into rendered-file without touching GitHub; `snapshot` writes the live settings to snapshot-file or snapshot-dir | -| `rendered-file` | (empty) | `mode: render` only (required there): where the rendered document is written, exactly what `apply` would run | -| `snapshot-file` | (empty) | `mode: snapshot` only (one of the two required there): where one repository's live settings are written as a settings document | -| `snapshot-dir` | (empty) | `mode: snapshot` only (one of the two required there): directory receiving one `/.yml` per multi-repo target | -| `on-missing-permission` | `fail` | `warn` skips sections the token cannot access (partial success) | -| `required-sections` | (empty) | Sections that must fully apply even under `warn` | -| `sections` | (all declared) | Comma-separated allowlist of sections to process (apply, check, and snapshot; rejected in `mode: render`) | -| `api-version` | `2022-11-28` | `X-GitHub-Api-Version` header; override to opt into a newer REST API version | -| `repos` | (empty) | Multi-repo remote mode: `owner/name` list (comma/newline), or `*` to discover owned repos | -| `repos-dir` | (empty) | Multi-repo central mode: directory of per-repo settings files in this repo | -| `defaults-file` | (empty) | YAML applied to every multi-repo target without a settings file (multi-repo mode only) | -| `layering` | `deep` | `mode: render` only: how every list section's entries combine across layers, by the section's key; `replace` lets the higher list win, `shallow` unions and swaps a same-key entry, `deep` unions and merges a same-key pair field by field; a layer's `_layering` overrides it | -| `undeclared` | (each list's default) | `keep` or `delete`: the fallback policy for every list that takes `_undeclared`, below a wrapper's and the file's own; unset, each list's default applies ([the undeclared policy](undeclared-policy.md)) | -| `private-repos` | `redact` | `redact` hides private and internal targets from public logs, summary, and outputs; `show` reveals them | -| `private-report` | `none` | `issue` delivers each redacted target's full report to a reused issue on that target repository; `issue-on-failure` writes that issue only when the target fails or drifts, closing it once healthy; `artifact` uploads all reports as one age-encrypted workflow artifact; rejected with `private-repos: show` | -| `report-public-key` | (empty) | The `age1...` recipient the `artifact` channel encrypts reports to; required with `private-report: artifact`, rejected otherwise | -| `visibility` | `all` | Discovery-only: keep `public`, `private`, or `internal` repositories | -| `archived` | `skip` | Discovery-only: `skip`, `include`, or `only` archived repositories | -| `forks` | `include` | Discovery-only: `include`, `exclude`, or `only` forks | -| `exclude` | (empty) | Discovery-only: `*` wildcard patterns (name, or `owner/name` if the pattern has a `/`) to drop | -| `topics` | (empty) | Discovery-only: keep repositories carrying at least one listed topic | -| `affiliation` | `owner` | Discovery-only: `owner`, `collaborator`, `organization_member` (comma list) | - +| name | description | required | default | +| --- | --- | --- | --- | +| `token` |

Token used for the API calls. Most sections need a fine-grained PAT with Administration read/write on the repository - the default GITHUB_TOKEN can never hold that permission.

| `false` | `${{ github.token }}` | +| `repository` |

Target repository (owner/name). Defaults to the current repository. Single-repo mode only; cannot be combined with repos or repos-dir.

| `false` | `""` | +| `settings-file` |

Path to the settings YAML file: exactly one in apply and check. In mode: render, the ordered list of settings files to fold instead, newline- or comma-separated, lowest layer first. Newlines and commas are list separators in every mode, so a settings-file path can never contain a comma. Single-repo and render modes only; multi-repo targets read repos-dir files or each repository's own .github/settings.yml, so overriding it alongside repos or repos-dir fails the run.

| `false` | `.github/settings.yml` | +| `mode` |

apply (mutate), check (report drift, exit 1 on any), render (fold the settings-file layers into one document written to rendered-file, with no token and no GitHub API call; render reads only settings-file, rendered-file, layering, and undeclared, ignores token, and rejects every other input set to a non-default value, since each controls an apply or check run), or snapshot (read the live settings of the target repositories back and write each as a settings document to snapshot-file or under snapshot-dir; nothing is written to GitHub, the document reaches only the file, and every input that controls an apply, a check, or a render is rejected). check makes no settings changes, though a private report may still be delivered.

| `false` | `apply` | +| `rendered-file` |

mode: render only, and required there: the path the rendered settings document is written to (parent directories are created). The file holds exactly what apply would run: every section validated, each section that takes an undeclared policy in its policy-wrapper form with the policy made explicit, the other sections in their own shape, and the _layering directives dropped. Feed it to a later apply or check step as its settings-file. Must not name one of the settings-file layers (the render would overwrite it). Fails when set in apply or check.

| `false` | `""` | +| `snapshot-file` |

mode: snapshot only, and exactly one of snapshot-file and snapshot-dir is required there: the path one repository's live settings are written to as a settings document (parent directories are created). The target is the repository input, defaulting to the current repository, so it cannot be combined with repos or repos-dir. The header pins the schema, names the repository and the moment, and lists every section note; secret values GitHub never reveals become $NAME references to export before an apply. Must not be .github/settings.yml, the file apply and check read: the snapshot would overwrite the document you author, so write it beside that file and copy it over deliberately. Fails when set in apply, check, or render.

| `false` | `""` | +| `snapshot-dir` |

mode: snapshot only, and exactly one of snapshot-file and snapshot-dir is required there: the directory the multi-repo targets' live settings are written under, one owner/name.yml per target (the repos-dir layout, so the directory can later serve as a repos-dir). The targets come from repos and repos-dir exactly as in a multi-repo apply, discovery filters included; defaults-file does not apply. Must be disjoint from the repos-dir (not the same directory, not above it, not below it): the snapshots would overwrite the central files or be read back as central files. Fails when set in apply, check, or render.

| `false` | `""` | +| `on-missing-permission` |

fail (default) or warn. Under warn, sections the token cannot access are skipped with a warning and the run stays green (partial success).

| `false` | `fail` | +| `required-sections` |

Comma-separated section names that must fully apply even under on-missing-permission: warn (minimum requirements). Every name must also be allowed by the "sections" input when that allowlist is set; a required section the allowlist excludes is rejected up front, because the run could never attempt it.

| `false` | `""` | +| `sections` |

Optional comma-separated allowlist of sections to process; unset, every declared section is processed. apply, check, and snapshot only: mode: render writes every section its layers declare, so the allowlist belongs on the step that runs the rendered document and fails the render when set.

| `false` | `""` | +| `api-version` |

X-GitHub-Api-Version header value. Override to opt into a newer REST API version before this action defaults to it.

| `false` | `2022-11-28` | +| `repos` |

Multi-repo remote mode: comma- or newline-separated owner/name targets, each applied from its own .github/settings.yml (default branch), or "*" alone to discover every repository the token's user owns, filterable via the visibility, archived, forks, exclude, topics, and affiliation inputs. Combinable with repos-dir; a repos-dir file for the same repository wins.

| `false` | `""` | +| `repos-dir` |

Multi-repo central mode: a directory in the checked-out admin repository holding per-repo settings files - name.yml (same owner as this repository) or owner/name.yml. Requires actions/checkout.

| `false` | `""` | +| `defaults-file` |

YAML settings document applied to every multi-repo target that has no settings file of its own (a repos target without .github/settings.yml, which is otherwise skipped). A target with its own file is applied as written; the defaults are never merged into it. With repos: "*" every discovered repository without a settings file receives the defaults; run mode: check first. Multi-repo mode only; fails when set without repos or repos-dir.

| `false` | `""` | +| `layering` |

mode: render only: replace, shallow, or deep (default), the run-wide default for how every list section's entries combine with the layers below them, each section by its own key (a label's name, a ruleset's name, a secret's name, and so on). replace lets the higher list win wholesale; shallow unions the entries by key and swaps a same-key entry for the higher one; deep unions by key and merges a same-key pair field by field, a nested keyed list (a ruleset's rules, by type) unioning the same way. A layer's own _layering directive, at its top level or on a section's {entries} wrapper, overrides it per file or per section. Lists outside the list sections are replaced by the higher layer's. Fails when set in apply or check.

| `false` | `""` | +| `undeclared` |

keep or delete: the run-wide fallback for what apply does to a live resource a list does not declare, for every list that takes the _undeclared knob (the sixteen knobbed sections and an environment's variables, secrets, deployment branch policies, and deployment protection rules). Unset by default, so each list's own default applies. A list's wrapper _undeclared wins over the file's top-level _undeclared, which wins over this input. In mode: render the resolved policy is written into every list of the rendered document, so a later apply of that document needs no undeclared input of its own. Rejected in mode: snapshot.

| `false` | `""` | +| `private-repos` |

redact (default) or show. Under redact, private and internal targets are hidden from this run's public logs, summary, and outputs: their slug becomes a "private repository #N" placeholder, live values and error bodies are replaced with "hidden (private repository)", and each slug is registered with the runner's secret masker. A target equal to GITHUB_REPOSITORY is never redacted. show reveals everything (today's behavior); only use it when the run's logs are not publicly readable.

| `false` | `redact` | +| `private-report` |

none (default), issue, issue-on-failure, or artifact. Delivers the full unredacted report only for redacted targets the visibility probe proves private or internal (an unknown visibility is redacted but excluded from delivery). Under issue, each such target's report is delivered as a reused, marker-labelled issue on that target repository itself (the one GitHub-private channel a public run has): the body is replaced every run, and the issue is opened when the target fails or drifts and closed when it is healthy. issue-on-failure is the quiet variant: a failing or drifting target gets the same issue, but a healthy run only closes a still-open issue from a previous failure and otherwise writes nothing - no issue ever appears on a repository that never needed attention (though a declared labels section still creates the marker label, and a manually-removed marker label defers the close: the next failing run reattaches it, and the first healthy run after that closes the issue). Under artifact, those reports are concatenated, age-encrypted to report-public-key, and uploaded as one workflow artifact (settings-as-code-private-report) for readers who hold the key but no GitHub access to the targets; the artifact channel needs the Actions artifact service, so on GitHub Enterprise Server it warns and uploads nothing. Applies only to redacted targets, so it is rejected alongside private-repos: show. Report delivery writes even in mode: check, and its failure never changes the run's result.

| `false` | `none` | +| `report-public-key` |

The age recipient (a public key starting with age1) the artifact channel encrypts every report to; safe to commit in the workflow. Generate a keypair with "age-keygen -o key.txt", keep key.txt secret, and decrypt a downloaded artifact with "age -d -i key.txt private-report.md.age". Required when private-report is artifact and rejected otherwise.

| `false` | `""` | +| `visibility` |

Keeps only repositories of this visibility in repos: "*" discovery. One of all (default), public, private, or internal; internal is matched client-side (Enterprise only). Fails if set outside that discovery.

| `false` | `""` | +| `archived` |

Archived-repository policy for repos: "*" discovery. One of skip (default; settings writes fail on archived repositories), include, or only (mostly useful with mode: check). Fails if set outside that discovery.

| `false` | `""` | +| `forks` |

Fork policy for repos: "*" discovery. One of include (default), exclude, or only. Fails if set outside that discovery.

| `false` | `""` | +| `exclude` |

Comma- or newline-separated wildcard patterns removing repositories from repos: "*" discovery. An asterisk matches any characters; a pattern containing "/" matches the full owner/name, any other the name alone. Case-insensitive. Fails if set outside that discovery.

| `false` | `""` | +| `topics` |

Comma- or newline-separated topics; repos: "*" discovery keeps only repositories carrying at least one of them. Unrelated to the topics settings section. Fails if set outside that discovery.

| `false` | `""` | +| `affiliation` |

Comma-separated affiliations for repos: "*" discovery, passed to the GitHub /user/repos listing. Any of owner (default), collaborator, organization_member; the list replaces the default, so use owner,collaborator to widen rather than move discovery. Fails if set outside that discovery.

| `false` | `""` | + + +
The discovery-only inputs apply to `repos: "*"`; the [multi-repo guide](../operate/multi-repo.md) covers the filters and the two sourcing modes. diff --git a/package.json b/package.json index 5694a104..b6b9164e 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,14 @@ "test": "bun test", "test:e2e": "bun test/e2e/run.ts", "fuzz": "bun test/e2e/fuzz.ts", - "build": "bun run build:bundle && bun run build:lib && bun run build:schema && bun run build:docs && bun run build:action-docs", + "build": "bun run build:gaps-index && bun run build:bundle && bun run build:lib && bun run build:schema && bun run build:docs && bun run build:action-docs && bun run build:inputs-table", "build:bundle": "bun build src/main.ts --target=node --outfile lib/index.js", "build:lib": "bun x tsdown", "build:schema": "bun .github/scripts/gen-settings-schema.ts", "build:docs": "bun .github/scripts/gen-docs.ts", "build:action-docs": "bun .github/scripts/gen-action-docs.ts", + "build:inputs-table": "bun .github/scripts/gen-inputs-table.ts", + "build:gaps-index": "bun .github/scripts/gen-gaps-index.ts", "build:check": "bun .github/scripts/generated.ts", "prepare": "lefthook install || true" }, @@ -88,6 +90,7 @@ "@octokit/openapi-webhooks": "12.1.0", "@types/bun": "1.4.0", "@types/node": "26.4.1", + "action-docs": "2.5.1", "ajv": "8.20.0", "ajv-formats": "3.0.1", "entities": "8.1.0", diff --git a/src/flows/inputs.ts b/src/flows/inputs.ts index e8d90a6c..18261703 100644 --- a/src/flows/inputs.ts +++ b/src/flows/inputs.ts @@ -40,21 +40,18 @@ const DEFAULT_PRIVATE_REPORT = "none" satisfies PrivateReportChannel; const DEFAULT_LAYERING = "deep" satisfies Layering; /** - * One input's action.yml entry and its row in the generated Inputs table on docs/reference/inputs.md. The runner - * applies the defaults; parseConfig() falls back to them outside the runner. + * One input's action.yml entry. The runner applies the defaults; parseConfig() falls back to them outside the + * runner. */ export interface InputDecl { - /** The action.yml description; the generator folds it to width. */ + /** + * The action.yml description; the generator folds it to width. Plain prose: action-docs runs it through a + * markdown renderer for the docs table, so a paired "*" or "_" would italicize, "..." would become an + * ellipsis, a "|" would split the cell, and a "<" would open a tag (test/docs/inputs.test.ts pins the rendering). + */ readonly description: string; /** The action.yml default, verbatim (an empty string means "unset"). */ readonly default: string; - /** The Inputs table's Meaning cell: the one-line gist. */ - readonly summary: string; - /** - * The Inputs table's Default cell when the raw default is not what a reader should see: an expression, a prose - * fallback, or the effective value for an empty raw default. - */ - readonly shownDefault?: string; /** * A comma- or newline-separated list. parseConfig reads such an input only * through its list() port (repos is split by the target resolver instead), @@ -64,8 +61,9 @@ export interface InputDecl { } /** - * The single source the inputs reference page and action.yml are generated from (bun run build:action-docs), in their - * listing order; adding an input here is the whole declaration. A new mode's inputs go beside their mode's. + * The single source action.yml is generated from (bun run build:action-docs), in its listing order; the inputs + * reference page's table is action-docs's rendering of that action.yml (bun run build:inputs-table). Adding an + * input here is the whole declaration. A new mode's inputs go beside their mode's. */ export const INPUT_DECLS = { token: { @@ -73,15 +71,11 @@ export const INPUT_DECLS = { "Token used for the API calls. Most sections need a fine-grained PAT with Administration read/write on the repository - the default GITHUB_TOKEN can never hold that permission.", // biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression the runner resolves, not a JS template default: "${{ github.token }}", - summary: "Token for the API calls (see [Token permissions](docs/reference/permissions.md))", - shownDefault: "`github.token`", }, repository: { description: "Target repository (owner/name). Defaults to the current repository. Single-repo mode only; cannot be combined with repos or repos-dir.", default: "", - summary: "Target `owner/name` (single-repo mode only)", - shownDefault: "current repo", }, "settings-file": { description: @@ -92,8 +86,6 @@ export const INPUT_DECLS = { "or each repository's own .github/settings.yml, so overriding it alongside repos or " + "repos-dir fails the run.", default: DEFAULT_SETTINGS_FILE, - summary: - "Settings file path (single-repo mode); in `mode: render`, the ordered list of layers to fold, low to high", list: true, }, mode: { @@ -107,11 +99,6 @@ export const INPUT_DECLS = { "the file, and every input that controls an apply, a check, or a render is rejected). check " + "makes no settings changes, though a private report may still be delivered.", default: "apply", - summary: - "`apply` mutates; `check` reports drift and exits 1 on any, making no settings changes (a " + - "private report may still be delivered); `render` folds the settings-file layers into " + - "rendered-file without touching GitHub; `snapshot` writes the live settings to snapshot-file " + - "or snapshot-dir", }, "rendered-file": { description: @@ -123,8 +110,6 @@ export const INPUT_DECLS = { "step as its settings-file. Must not name one of the settings-file layers (the render would " + "overwrite it). Fails when set in apply or check.", default: "", - summary: - "`mode: render` only (required there): where the rendered document is written, exactly what `apply` would run", }, "snapshot-file": { description: @@ -138,28 +123,23 @@ export const INPUT_DECLS = { "document you author, so write it beside that file and copy it over deliberately. Fails " + "when set in apply, check, or render.", default: "", - summary: - "`mode: snapshot` only (one of the two required there): where one repository's live settings are written as a settings document", }, "snapshot-dir": { description: "mode: snapshot only, and exactly one of snapshot-file and snapshot-dir is required there: " + "the directory the multi-repo targets' live settings are written under, one " + - "/.yml per target (the repos-dir layout, so the directory can later serve as a " + + "owner/name.yml per target (the repos-dir layout, so the directory can later serve as a " + "repos-dir). The targets come from repos and repos-dir exactly as in a multi-repo apply, " + "discovery filters included; defaults-file does not apply. Must be disjoint from the " + "repos-dir (not the same directory, not above it, not below it): the snapshots would " + "overwrite the central files or be read back as central files. Fails when set in apply, " + "check, or render.", default: "", - summary: - "`mode: snapshot` only (one of the two required there): directory receiving one `/.yml` per multi-repo target", }, "on-missing-permission": { description: "fail (default) or warn. Under warn, sections the token cannot access are skipped with a warning and the run stays green (partial success).", default: "fail", - summary: "`warn` skips sections the token cannot access (partial success)", }, "required-sections": { description: @@ -168,23 +148,21 @@ export const INPUT_DECLS = { "when that allowlist is set; a required section the allowlist excludes is rejected up " + "front, because the run could never attempt it.", default: "", - summary: "Sections that must fully apply even under `warn`", list: true, }, sections: { description: - "Optional comma-separated allowlist of sections to process. apply, check, and snapshot only: mode: render writes every section its layers declare, so the allowlist belongs on the step that runs the rendered document and fails the render when set.", + "Optional comma-separated allowlist of sections to process; unset, every declared section is " + + "processed. apply, check, and snapshot only: mode: render writes every section its layers " + + "declare, so the allowlist belongs on the step that runs the rendered document and fails the " + + "render when set.", default: "", - summary: - "Comma-separated allowlist of sections to process (apply, check, and snapshot; rejected in `mode: render`)", - shownDefault: "(all declared)", list: true, }, "api-version": { description: "X-GitHub-Api-Version header value. Override to opt into a newer REST API version before this action defaults to it.", default: DEFAULT_API_VERSION, - summary: "`X-GitHub-Api-Version` header; override to opt into a newer REST API version", }, repos: { description: @@ -194,15 +172,12 @@ export const INPUT_DECLS = { "exclude, topics, and affiliation inputs. Combinable with repos-dir; a repos-dir file " + "for the same repository wins.", default: "", - summary: - "Multi-repo remote mode: `owner/name` list (comma/newline), or `*` to discover owned repos", list: true, }, "repos-dir": { description: - "Multi-repo central mode: a directory in the checked-out admin repository holding per-repo settings files - .yml (same owner as this repository) or /.yml. Requires actions/checkout.", + "Multi-repo central mode: a directory in the checked-out admin repository holding per-repo settings files - name.yml (same owner as this repository) or owner/name.yml. Requires actions/checkout.", default: "", - summary: "Multi-repo central mode: directory of per-repo settings files in this repo", }, "defaults-file": { description: @@ -213,25 +188,18 @@ export const INPUT_DECLS = { "defaults; run mode: check first. Multi-repo mode only; fails when set without repos or " + "repos-dir.", default: "", - summary: - "YAML applied to every multi-repo target without a settings file (multi-repo mode only)", }, layering: { description: "mode: render only: replace, shallow, or deep (default), the run-wide default for how every " + "list section's entries combine with the layers below them, each section by its own key (a " + - "label's name, a ruleset's name, a secret's name, ...). replace lets the higher list win " + + "label's name, a ruleset's name, a secret's name, and so on). replace lets the higher list win " + "wholesale; shallow unions the entries by key and swaps a same-key entry for the higher one; " + "deep unions by key and merges a same-key pair field by field, a nested keyed list (a " + "ruleset's rules, by type) unioning the same way. A layer's own _layering directive, at its " + "top level or on a section's {entries} wrapper, overrides it per file or per section. Lists " + "outside the list sections are replaced by the higher layer's. Fails when set in apply or check.", default: "", - summary: - "`mode: render` only: how every list section's entries combine across layers, by the section's key; " + - "`replace` lets the higher list win, `shallow` unions and swaps a same-key entry, `deep` unions and " + - "merges a same-key pair field by field; a layer's `_layering` overrides it", - shownDefault: "`deep`", }, undeclared: { description: @@ -243,10 +211,6 @@ export const INPUT_DECLS = { "resolved policy is written into every list of the rendered document, so a later apply of " + "that document needs no undeclared input of its own. Rejected in mode: snapshot.", default: "", - summary: - "`keep` or `delete`: the fallback policy for every list that takes `_undeclared`, below a wrapper's " + - "and the file's own; unset, each list's default applies ([the undeclared policy](docs/reference/undeclared-policy.md))", - shownDefault: "(each list's default)", }, "private-repos": { description: @@ -257,8 +221,6 @@ export const INPUT_DECLS = { "equal to GITHUB_REPOSITORY is never redacted. show reveals everything (today's " + "behavior); only use it when the run's logs are not publicly readable.", default: DEFAULT_PRIVATE_REPOS, - summary: - "`redact` hides private and internal targets from public logs, summary, and outputs; `show` reveals them", }, "private-report": { description: @@ -281,69 +243,51 @@ export const INPUT_DECLS = { "targets, so it is rejected alongside private-repos: show. Report delivery writes even " + "in mode: check, and its failure never changes the run's result.", default: DEFAULT_PRIVATE_REPORT, - summary: - "`issue` delivers each redacted target's full report to a reused issue on that target " + - "repository; `issue-on-failure` writes that issue only when the target fails or drifts, " + - "closing it once healthy; `artifact` uploads all reports as one age-encrypted workflow " + - "artifact; rejected with `private-repos: show`", }, "report-public-key": { description: - 'The age recipient (an "age1..." public key) the artifact channel encrypts every report ' + + "The age recipient (a public key starting with age1) the artifact channel encrypts every report " + 'to; safe to commit in the workflow. Generate a keypair with "age-keygen -o key.txt", ' + 'keep key.txt secret, and decrypt a downloaded artifact with "age -d -i key.txt ' + 'private-report.md.age". Required when private-report is artifact and rejected otherwise.', default: "", - summary: - "The `age1...` recipient the `artifact` channel encrypts reports to; required with `private-report: artifact`, rejected otherwise", }, visibility: { description: - 'Keeps only repositories of this visibility in repos: "*" discovery. One of all (default), public, private, or internal; internal is matched client-side (Enterprise only). Fails if set without repos: "*".', + 'Keeps only repositories of this visibility in repos: "*" discovery. One of all (default), public, private, or internal; internal is matched client-side (Enterprise only). Fails if set outside that discovery.', default: "", - summary: "Discovery-only: keep `public`, `private`, or `internal` repositories", - shownDefault: `\`${DEFAULT_DISCOVERY_FILTERS.visibility}\``, }, archived: { description: - 'Archived-repository policy for repos: "*" discovery. One of skip (default; settings writes fail on archived repositories), include, or only (mostly useful with mode: check). Fails if set without repos: "*".', + 'Archived-repository policy for repos: "*" discovery. One of skip (default; settings writes fail on archived repositories), include, or only (mostly useful with mode: check). Fails if set outside that discovery.', default: "", - summary: "Discovery-only: `skip`, `include`, or `only` archived repositories", - shownDefault: `\`${DEFAULT_DISCOVERY_FILTERS.archived}\``, }, forks: { description: - 'Fork policy for repos: "*" discovery. One of include (default), exclude, or only. Fails if set without repos: "*".', + 'Fork policy for repos: "*" discovery. One of include (default), exclude, or only. Fails if set outside that discovery.', default: "", - summary: "Discovery-only: `include`, `exclude`, or `only` forks", - shownDefault: `\`${DEFAULT_DISCOVERY_FILTERS.forks}\``, }, exclude: { description: 'Comma- or newline-separated wildcard patterns removing repositories from repos: "*" ' + - 'discovery. "*" matches any characters; a pattern containing "/" matches the full ' + - 'owner/name, any other the name alone. Case-insensitive. Fails if set without repos: "*".', + 'discovery. An asterisk matches any characters; a pattern containing "/" matches the full ' + + "owner/name, any other the name alone. Case-insensitive. Fails if set outside that discovery.", default: "", - summary: - "Discovery-only: `*` wildcard patterns (name, or `owner/name` if the pattern has a `/`) to drop", list: true, }, topics: { description: - 'Comma- or newline-separated topics; repos: "*" discovery keeps only repositories carrying at least one of them. Unrelated to the topics settings section. Fails if set without repos: "*".', + 'Comma- or newline-separated topics; repos: "*" discovery keeps only repositories carrying at least one of them. Unrelated to the topics settings section. Fails if set outside that discovery.', default: "", - summary: "Discovery-only: keep repositories carrying at least one listed topic", list: true, }, affiliation: { description: 'Comma-separated affiliations for repos: "*" discovery, passed to the GitHub /user/repos ' + - "listing. Any of owner, collaborator, organization_member; the list replaces the default " + - "(owner), so use owner,collaborator to widen rather than move discovery. Fails if set " + - 'without repos: "*".', + "listing. Any of owner (default), collaborator, organization_member; the list replaces the " + + "default, so use owner,collaborator to widen rather than move discovery. Fails if set " + + "outside that discovery.", default: "", - summary: "Discovery-only: `owner`, `collaborator`, `organization_member` (comma list)", - shownDefault: `\`${DEFAULT_DISCOVERY_FILTERS.affiliation.join(",")}\``, list: true, }, } as const satisfies Record; diff --git a/test/action/action-yml.test.ts b/test/action/action-yml.test.ts index 691aa24e..f3a73c19 100644 --- a/test/action/action-yml.test.ts +++ b/test/action/action-yml.test.ts @@ -28,8 +28,9 @@ describe("action.yml <-> README", () => { }); describe("input declarations <-> discovery defaults", () => { - test("each discovery filter declares an empty default and shows its effective one", () => { - // A filter is "explicitly set" when its raw input is not "", so a non-empty declared default would defeat that detection. + test("each discovery filter declares an empty default and names its effective one", () => { + // A filter is "explicitly set" when its raw input is not "", so a non-empty declared default would defeat that + // detection; the description is then the only place the reader learns the effective default. const effective: Partial> = { visibility: DEFAULT_DISCOVERY_FILTERS.visibility, archived: DEFAULT_DISCOVERY_FILTERS.archived, @@ -40,17 +41,12 @@ describe("input declarations <-> discovery defaults", () => { const decl: InputDecl = INPUT_DECLS[name]; expect(decl.default, `the "${name}" declaration must default to ""`).toBe(""); const value = effective[name]; - if (value === undefined) { - expect(decl.shownDefault, `"${name}" has no effective default to show`).toBeUndefined(); - continue; + if (value !== undefined) { + expect( + decl.description, + `the "${name}" description does not name "${value}" as its default`, + ).toContain(`${value} (default`); } - expect( - decl.description.includes(value), - `the "${name}" description does not mention its default "${value}"`, - ).toBe(true); - expect(decl.shownDefault, `the Inputs table must show "${name}" defaulting to ${value}`).toBe( - `\`${value}\``, - ); } }); }); diff --git a/test/docs/inputs.test.ts b/test/docs/inputs.test.ts new file mode 100644 index 00000000..c0513ba3 --- /dev/null +++ b/test/docs/inputs.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { INPUTS_PAGE_PATH, MARKER } from "../../.github/scripts/gen-inputs-table.js"; +import { INPUT_DECLS } from "../../src/flows/inputs.js"; +import { ROOT } from "../root.js"; + +/** + * action-docs runs each description through a markdown renderer before it reaches the table cell, so prose action.yml + * and the CLI help show verbatim can come out italicized (a paired "*" or "_"), as an ellipsis character ("..."), or + * swallowed as an HTML tag (""); nothing else on the page would notice. The region is pinned whole (the + * heading, one row per declaration verbatim and in order, nothing else), the same shape gen-inputs-table.ts refuses + * to regenerate over. A "|" and a "<" are the two characters the row comparison cannot catch: the renderer passes + * both through, so the expected row carries the split cell or the raw tag too. + */ +test("the inputs table region is exactly action-docs's ASCII rendering of every declaration", () => { + const page = readFileSync(join(ROOT, INPUTS_PAGE_PATH), "utf8"); + const parts = page.split(MARKER); + expect(parts).toHaveLength(3); + const region = parts[1] ?? ""; + const rows = Object.entries(INPUT_DECLS).map( + ([name, decl]) => + `| \`${name}\` |

${decl.description}

| \`false\` | \`${decl.default === "" ? '""' : decl.default}\` |`, + ); + expect(region.split("\n")).toEqual([ + "", + "## Inputs", + "", + "| name | description | required | default |", + "| --- | --- | --- | --- |", + ...rows, + "", + ]); + expect(region).toMatch(/^[\x20-\x7e\n]*$/); + for (const [name, decl] of Object.entries(INPUT_DECLS)) { + expect(decl.description, `the "${name}" description holds a "|" or a "<"`).not.toMatch(/[|<]/); + } +}); diff --git a/test/scripts/auto-fix-allowlist.test.ts b/test/scripts/auto-fix-allowlist.test.ts index ad4e90bf..ae1e6604 100644 --- a/test/scripts/auto-fix-allowlist.test.ts +++ b/test/scripts/auto-fix-allowlist.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parse } from "yaml"; -import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; +import { + generatedPaths, + generatorEntryPoint, + generatorScripts, +} from "../../.github/scripts/generated.js"; import { ROOT } from "../root.js"; /** @@ -83,7 +87,11 @@ function admitted(pattern: string, path: string): boolean { } const paths = generatedPaths(); -const generators = [...new Set(GENERATED_OUTPUTS.map((output) => output.generator))]; +const generators = generatorScripts(); +/** Each generator script with the repository file it runs; null for a shape the census cannot read. */ +const generatorFiles = generators.map( + (name) => [name, generatorEntryPoint(scripts[name] ?? "")] as const, +); describe("auto-fix.yml tracks the generated-output table", () => { test("the parser sees both allowlists, and they name the same paths", () => { @@ -159,7 +167,10 @@ describe("auto-fix.yml tracks the generated-output table", () => { }); test("a hand edit to a generated output or its generator triggers the fix", () => { - for (const path of [...paths, ...generators]) { + for (const [name, file] of generatorFiles) { + expect(file, `${name} is a package.json script of the shape bun .ts`).not.toBeNull(); + } + for (const path of [...paths, ...generatorFiles.map(([, file]) => file ?? "")]) { expect( triggerPaths.some((pattern) => new Bun.Glob(pattern).match(path)), `on.paths: ${path}`, @@ -169,10 +180,10 @@ describe("auto-fix.yml tracks the generated-output table", () => { test("the rebuild step runs exactly the generators, in table order", () => { // The graduation step regenerates the gaps index only when a gap graduates, so the index generator runs here - // too. `bun run build:x` resolves through package.json to the one generator it runs; any other shape - // resolves to nothing and fails the comparison. - const run = [...rebuildRun.matchAll(/^\s*bun (run )?(\S+)$/gm)].map(([, viaScript, name]) => - viaScript === undefined ? name : /^bun (\S+)$/.exec(scripts[name ?? ""] ?? "")?.[1], + // too. Every generator is a package.json script, so a bare `bun ` line here is a generator the table + // does not know and fails the comparison. + const run = [...rebuildRun.matchAll(/^\s*bun (\S+)(?: (\S+))?$/gm)].map(([, word, name]) => + word === "run" ? name : `${word}${name === undefined ? "" : ` ${name}`}`, ); expect(run).toEqual(generators); }); diff --git a/test/scripts/gen-action-docs.test.ts b/test/scripts/gen-action-docs.test.ts index 00b58b6b..e946abb5 100644 --- a/test/scripts/gen-action-docs.test.ts +++ b/test/scripts/gen-action-docs.test.ts @@ -10,7 +10,6 @@ import { renderCheckModeGatedReads, renderGatedReads, renderGrantSentence, - renderInputsTable, renderPolicyCountSentence, renderPolicyDefaultsTable, } from "../../.github/scripts/gen-action-docs.js"; @@ -102,66 +101,6 @@ describe("action.yml renderers", () => { }); }); -describe("Inputs table renderer", () => { - test("shows the declared default backticked, an empty one as (empty), and a shown default verbatim", () => { - expect( - renderInputsTable( - { - token: { - default: "an expression the runner resolves", - shownDefault: "`github.token`", - summary: "Token for the API calls", - }, - mode: { default: "apply", summary: "`apply` mutates; `check` reports" }, - repos: { default: "", summary: "Multi-repo remote mode" }, - visibility: { default: "", shownDefault: "`all`", summary: "Discovery-only: a | b" }, - archived: { default: "", summary: "kept \\| as is, but \\\\| gets escaped" }, - }, - ".", - ), - ).toBe( - [ - "| Input | Default | Meaning |", - "|---|---|---|", - "| `token` | `github.token` | Token for the API calls |", - "| `mode` | `apply` | `apply` mutates; `check` reports |", - "| `repos` | (empty) | Multi-repo remote mode |", - "| `visibility` | `all` | Discovery-only: a \\| b |", - "| `archived` | (empty) | kept \\| as is, but \\\\\\| gets escaped |", - ].join("\n"), - ); - for (const summary of ["two\nlines", "carriage\rreturn"]) { - expect(() => renderInputsTable({ x: { default: "", summary } }, ".")).toThrow( - /cannot contain a line break/, - ); - } - }); - - test("rebases a summary's root-relative links onto the page's directory, leaving URLs and fragments alone", () => { - const decls = { - token: { - default: "", - summary: - "see [permissions](docs/reference/permissions.md#what-to-grant), [the guide](docs/start/getting-started.md), and [GitHub](https://docs.github.com/x)", - }, - mode: { - default: "", - summary: - "[a](//docs.example/x), [b](HTTPS://x), [c](mailto:a@b), [d](/site/x.md), [e](docs/x.md#top), [f](#top)", - }, - }; - const rows = (table: string): string[] => table.split("\n").slice(2); - expect(rows(renderInputsTable(decls, "."))).toEqual([ - "| `token` | (empty) | see [permissions](docs/reference/permissions.md#what-to-grant), [the guide](docs/start/getting-started.md), and [GitHub](https://docs.github.com/x) |", - "| `mode` | (empty) | [a](//docs.example/x), [b](HTTPS://x), [c](mailto:a@b), [d](/site/x.md), [e](docs/x.md#top), [f](#top) |", - ]); - expect(rows(renderInputsTable(decls, "docs/reference"))).toEqual([ - "| `token` | (empty) | see [permissions](permissions.md#what-to-grant), [the guide](../start/getting-started.md), and [GitHub](https://docs.github.com/x) |", - "| `mode` | (empty) | [a](//docs.example/x), [b](HTTPS://x), [c](mailto:a@b), [d](/site/x.md), [e](../x.md#top), [f](#top) |", - ]); - }); -}); - describe("undeclared-policy renderers", () => { const sections = [ { key: "rulesets", undeclaredDefault: "keep" }, @@ -369,10 +308,6 @@ describe("generated files", () => { expect(shapes.get("action-inputs")?.test(body), body).toBe(false); } accepts("action-outputs", renderActionOutputs({ result: { description: "A | B." } })); - accepts( - "inputs-table", - renderInputsTable({ x: { default: "", shownDefault: "a", summary: "b | c" } }, "."), - ); const knobbed = [ { key: "labels", undeclaredDefault: "delete" }, { key: "rulesets", undeclaredDefault: "keep" }, diff --git a/test/scripts/gen-inputs-table.test.ts b/test/scripts/gen-inputs-table.test.ts new file mode 100644 index 00000000..799d3c3b --- /dev/null +++ b/test/scripts/gen-inputs-table.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { INPUTS_PAGE_PATH, MARKER, regionProblem } from "../../.github/scripts/gen-inputs-table.js"; +import { ROOT } from "../root.js"; + +const page = readFileSync(join(ROOT, INPUTS_PAGE_PATH), "utf8"); +const [intro, region, tail] = page.split(MARKER) as [string, string, string]; +const pageLine = (text: string, needle: string): number => + text.slice(0, text.indexOf(needle)).split("\n").length; +const refusal = (line: number): RegExp => + new RegExp( + `^line ${line} of docs/reference/inputs.md .*not a line of a rendered inputs table; move the markers back`, + ); + +/** action-docs itself accepts every one of these pages and rewrites the region; only the guard tells them apart. */ +test("the committed page and a fresh marker pair pass; a marker moved over prose is refused naming the line, a lone marker naming the count", () => { + expect(regionProblem(page)).toBeNull(); + expect(regionProblem(`${intro}${MARKER}\n${MARKER}${tail}`)).toBeNull(); + const paragraphEnd = tail.indexOf("\n\n## "); + const swallowing = `${intro}${MARKER}${region}${tail.slice(0, paragraphEnd)}\n${MARKER}${tail.slice(paragraphEnd)}`; + // The first swallowed line is the first blank after the rows: a rendering's only blank there is the line before the + // closing marker, and the moved marker leaves the page's own blank lines and its closing div in the region. + expect(regionProblem(swallowing)).toMatch(refusal(pageLine(swallowing, "\n\n"))); + expect(regionProblem(`${intro}${MARKER}${region}${tail}`)).toMatch( + /exactly two .* markers, found 1/, + ); + expect(regionProblem(`${intro}${MARKER}${region}${MARKER}${MARKER}${tail}`)).toMatch(/found 3/); + // action-docs replaces only the opening marker of an adjacent pair, leaving three on the page. + expect(regionProblem(`${intro}${MARKER}${MARKER}${tail}`)).toMatch( + new RegExp( + `^the closing .* marker on line ${pageLine(intro, "
") + 2} of .* must start its own line`, + ), + ); +}); + +/** A guard that checked only the `| ` prefix let action-docs erase the first two, and one that counted cells the + * third: an authored line that looks like a row, an authored table of another width, and an authored four-cell row + * with bare cells. Each is placed where a moved marker would swallow it. */ +test.each([ + ["| authored prose", "a one-cell line"], + ["| a | b |\n| --- | --- |\n| 1 | 2 |", "a two-column table"], + [ + "| Keep this warning | It is authored prose | do not erase | note |", + "a four-cell row with bare cells", + ], +])("%p between the markers is refused naming its line (%s)", (authored) => { + const swallowing = `${intro}${MARKER}${region}${authored}\n${MARKER}${tail}`; + expect(regionProblem(swallowing)).toMatch(refusal(pageLine(swallowing, authored))); + const replacing = `${intro}${MARKER}\n${authored}\n${MARKER}${tail}`; + expect(regionProblem(replacing)).toMatch(refusal(pageLine(replacing, authored))); +}); + +/** The check is one split per line, so a row of thousands of cells costs what its length costs; the four-cell regex + * this replaced backtracked exponentially on exactly this row (CodeQL's inefficient-regular-expression alert). */ +test("a 30-row table whose last row is malformed is refused naming that row, in linear time", () => { + const rows = Array.from( + { length: 29 }, + (_, i) => `| \`in${i}\` |

text

| \`false\` | \`""\` |`, + ); + const malformed = `| \`_\` | ${" | `true` | `true` | `` | `_` |".repeat(5000)}`; + const table = [ + "", + "## Inputs", + "", + "| name | description | required | default |", + "| --- | --- | --- | --- |", + ...rows, + malformed, + "", + ]; + const swallowing = `${intro}${MARKER}${table.join("\n")}${MARKER}${tail}`; + const started = performance.now(); + const problem = regionProblem(swallowing); + const elapsed = performance.now() - started; + expect(problem).toMatch(refusal(pageLine(swallowing, malformed))); + expect(elapsed).toBeLessThan(2000); +}); diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 7d79504f..8f851e2f 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { cpSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { extname, join } from "node:path"; -import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; +import { + GENERATED_OUTPUTS, + generatedPaths, + generatorEntryPoint, + generatorScripts, +} from "../../.github/scripts/generated.js"; import { hasGeneratedRegion, markerSyntaxFor, @@ -13,6 +18,13 @@ import { withTempDir } from "../temp-dir.js"; /** Only a file type with a marker syntax carries a region; a marker string anywhere else is test or script text. */ const regionFile = (path: string): boolean => extname(path) in SYNTAX_BY_EXTENSION; +/** action-docs's own region markers, the one grammar lib/generated-regions.ts does not read. */ +const ACTION_DOCS_MARKER = /