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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 1 addition & 46 deletions .github/scripts/gen-action-docs.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -102,39 +102,6 @@ function row(cells: readonly string[]): string {
return `| ${cells.map(cell).join(" | ")} |`;
}

function shownDefault(decl: Pick<InputDecl, "default" | "shownDefault">): 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<Record<string, Pick<InputDecl, "default" | "shownDefault" | "summary">>>,
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 ");
Expand Down Expand Up @@ -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<Record<string, readonly GeneratedRegion[]>> = {
Expand All @@ -393,7 +349,6 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
render: block(() => renderActionOutputs(OUTPUT_DECLS)),
},
],
[INPUTS_PAGE_PATH]: [inputsTableRegion("inputs-table", "## Inputs", INPUTS_PAGE_PATH)],
"docs/reference/undeclared-policy.md": [
{
name: "policy-count-sentence",
Expand Down
121 changes: 121 additions & 0 deletions .github/scripts/gen-inputs-table.ts
Original file line number Diff line number Diff line change
@@ -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 = '<!-- action-docs-inputs source="action.yml" -->';

/** 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` | <p>description</p> | `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("<p>") && cell.endsWith("</p>"),
(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`);
}
42 changes: 26 additions & 16 deletions .github/scripts/generated.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,64 @@
/**
* 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 <generator>` regenerates it in place. */
/** The package.json script that writes it; `bun run <generator>` 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";
}

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. */
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 <file>.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);
}
}
Expand All @@ -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);
}
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/auto-fix.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading