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
2 changes: 1 addition & 1 deletion .github/scripts/gen-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from "./lib/generated-regions.js";

const ROOT = join(import.meta.dir, "..", "..");
const COVERAGE_PATH = "COVERAGE.md";
export const COVERAGE_PATH = "COVERAGE.md";

/** The repository these pages document; the token form's name and description derive from it. */
const REPO_SLUG = "Vivswan/github-settings-as-code";
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/gen-gaps-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { join } from "node:path";

const ROOT = join(import.meta.dir, "..", "..");
const GAPS_DIR = "src/upstream-gaps";
const INDEX_PATH = `${GAPS_DIR}/index.ts`;
export const INDEX_PATH = `${GAPS_DIR}/index.ts`;

export const NON_GAP_FILE_NAMES: ReadonlySet<string> = new Set(["index.ts", "gap.ts"]);

Expand Down
75 changes: 75 additions & 0 deletions .github/scripts/generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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.
*/

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";

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. */
readonly generator: string;
/** Marker-delimited regions inside an authored file (lib/generated-regions.ts), 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. */
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" },
];

/** The distinct paths, in table order. */
export function generatedPaths(): string[] {
return [...new Set(GENERATED_OUTPUTS.map((output) => output.path))];
}

/** 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`);
process.exit(1);
}
}
const paths = generatedPaths();
// Working tree against the index: a regenerated file already staged passes, a stale one fails. git lists the
// drifted paths itself; an untracked registered path is listed the same way.
const untracked = Bun.spawnSync(
["git", "ls-files", "--others", "--exclude-standard", "--", ...paths],
{ cwd: ROOT, stderr: "inherit" },
);
if (untracked.exitCode !== 0) {
console.error("build:check: git ls-files failed");
process.exit(1);
}
const drifted = run(["git", "diff", "--exit-code", "--stat", "--", ...paths]) !== 0;
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",
);
process.exit(1);
}
console.log(`build:check: ${paths.length} generated files match their generators`);
}
8 changes: 7 additions & 1 deletion .github/scripts/lib/generated-regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export function markerSyntaxFor(path: string): MarkerSyntax {
}

// The name is spliced into a regex unescaped, so the grammar admits only regex-literal characters.
const REGION_NAME = /^[a-z0-9-]+$/;
const REGION_NAME_CLASS = "[a-z0-9-]+";
const REGION_NAME = new RegExp(`^${REGION_NAME_CLASS}$`);

function markerText(kind: "BEGIN" | "END", name: string): string {
const hint = kind === "BEGIN" ? String.raw`(?: \([^)\n]*\))?` : "";
Expand Down Expand Up @@ -93,6 +94,11 @@ function markerSpans(
});
}

/** True when `text` carries a BEGIN marker of any region in `syntax`: the scan the generated-output table is pinned against. */
export function hasGeneratedRegion(text: string, syntax: MarkerSyntax): boolean {
return markerSpans(text, "BEGIN", REGION_NAME_CLASS, syntax).length > 0;
}

/** Region `name`'s marker spans: exactly one BEGIN and one END, in that order, else a throw. */
export function regionBounds(
text: string,
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Code is the source of truth: this section holds only the rules and the decisions

### Hard rules

- Generated artifacts (`lib/settings.schema.json`, the generated docs, the generated `action.yml` regions) are regenerated, never hand-edited; `bun run build:check` fails on drift.
- Generated artifacts (`lib/settings.schema.json`, `src/upstream-gaps/index.ts`, the generated docs and `action.yml` regions) are regenerated, never hand-edited; `.github/scripts/generated.ts` lists them and `bun run build:check` fails on drift.
- `lib/index.js` (the action bundle) and `lib/pkg/` (the npm library) are built, never committed on main; the packaged `build` branch carries them.
- Every GitHub list call goes through `listAll()` or `listAllEnveloped()`, and every API error through `call()`/`throwFor()`, so the permission policy holds (`src/sections/contract/requests.ts`).
- The import layering of `src/` is declared in `architecture.yml`; a new cross-layer import is a deliberate edit to that file.
Expand Down
16 changes: 15 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all
## Toolchain

- `src/` is TypeScript built with [bun](https://bun.com). The scripts in `package.json` are the commands; `bun run check` is the whole local gate.
- `lib/settings.schema.json` is the one committed generated artifact; `bun run build:check` fails when it or the generated docs drift.
- Committed generated output is the table in `.github/scripts/generated.ts`: `lib/settings.schema.json`, `src/upstream-gaps/index.ts`, and the generated regions of `action.yml`, `COVERAGE.md`, and the docs pages.
- `bun run build:check` regenerates every table entry and fails on drift.
- `lib/index.js` (the action bundle) and `lib/pkg/` (the npm library) are built where they are needed and never committed on `main`. Every runtime dependency is compiled into them.
- [COVERAGE.md](COVERAGE.md) is the inventory of the supported API surface. A change that adds or extends a section keeps it in step.

Expand All @@ -15,6 +16,19 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all
- `bun run check:compat` (in `bun run check` and in CI) rejects a malformed marker and any marker whose major is at or below `package.json`'s, and prints the remaining markers grouped by major.
- A release PR's tree already carries the version it cuts, so the same check makes a major release PR unmergeable until every marker for that major is deleted on `main` first: with 3.0.0 in preparation, every v3-marked path goes before v3 cuts.

## Code conventions

- Line caps: code wraps at biome's `lineWidth` of 100. The fleet's check-file-size caps source, test, workflow, and shell lines at 256 characters; markdown prose has no width cap. A comment block is at most 10 lines.
- Markdown keeps one source line per paragraph or list item, so a long item is split into items, never wrapped.
- A source file under `src/` or `.github/scripts/` opens with a one-paragraph header comment saying what the file owns; test files need none.
- Tests live in two places: a section's unit tests sit beside it in `src/sections/<key>/`; everything else is under `test/`, mirroring `src/`.

## Tests

- Every temp directory a test creates is removed on every exit path, failure included: `withTempDir()` from `test/temp-dir.ts`, or a try/finally of its own. A fixture that outlives one test removes its dir when it ends (the release-pipeline fixture in afterAll, the e2e bundle on process exit).
- An Io a test records through is `captureIo()` from `test/io/capture.ts`: every channel in its own list and in one ordered event log.
- A repository-relative path in a test resolves from `ROOT` in `test/root.ts`; a file beside the test resolves from `import.meta.dir`.

## End-to-end tests

The end-to-end tests build the bundle to a temp path and run it as a subprocess against a mock GitHub API, so they exercise the same single-file bundle a release ships.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"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:check": "bun run build:schema && git diff --exit-code lib/settings.schema.json && ! git ls-files --others --exclude-standard lib/settings.schema.json | grep . && bun run build:docs && bun run build:action-docs && git diff --exit-code README.md COVERAGE.md action.yml docs/start/getting-started.md docs/reference/sections.md docs/reference/inputs.md docs/reference/architecture.md docs/reference/undeclared-policy.md docs/reference/permissions.md docs/operate/check-mode.md && ! git ls-files --others --exclude-standard README.md COVERAGE.md action.yml docs/start/getting-started.md docs/reference/sections.md docs/reference/inputs.md docs/reference/architecture.md docs/reference/undeclared-policy.md docs/reference/permissions.md docs/operate/check-mode.md | grep .",
"build:check": "bun .github/scripts/generated.ts",
"prepare": "lefthook install || true"
},
"dependencies": {
Expand Down
22 changes: 6 additions & 16 deletions src/sections/actions_secrets/actions_secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test";
import { executePlan } from "../../../src/engine/execute.js";
import { runForRepo, validateSettingsDoc } from "../../../src/engine/orchestrate.js";
import { type GithubClient, SECRET_RESPONSE_WITHHELD } from "../../../src/github/api.js";
import { type Io, maskRegistry } from "../../../src/io.js";
import {
MOCK_SECRETS_PUBLIC_KEY,
mockSodiumReady,
unsealSecretValue,
} from "../../../test/e2e/mock/secrets.js";
import { captureIo } from "../../../test/io/capture.js";
import { MockApi } from "../../../test/mock-api.js";
import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js";
import { REPO } from "../../../test/sections/section-run.js";
Expand Down Expand Up @@ -376,20 +376,10 @@ describe("actions_secrets execution", () => {
const api = new MockApi({ [LIST]: listOf(), [PUBLIC_KEY]: KEY_ROUTE }).allowMutations(
"PUT /repos/o/r/actions/secrets/DEPLOY_TOKEN",
);
const masked: string[] = [];
const mutationsAtMaskTime: number[] = [];
const logs: string[] = [];
const io: Io = {
annotate: (level, message) => logs.push(`${level}: ${message}`),
log: (line) => logs.push(line),
debug: () => {},
summary: () => {},
output: () => {},
...maskRegistry((value) => {
mutationsAtMaskTime.push(api.mutations().length);
masked.push(value);
}),
};
const { io, masks, logs, annotations } = captureIo(() =>
mutationsAtMaskTime.push(api.mutations().length),
);
const validated = validateSettingsDoc(
{ actions_secrets: [{ name: "DEPLOY_TOKEN", value: "$DEPLOY_TOKEN" }] },
"settings.yml",
Expand All @@ -412,12 +402,12 @@ describe("actions_secrets execution", () => {
io,
);
expect(result.result).toBe("applied");
expect(masked).toEqual(["s3cret-plaintext"]);
expect(masks).toEqual(["s3cret-plaintext"]);
expect(mutationsAtMaskTime).toEqual([0]);
expect(unsealSecretValue(sealedPayload(api.mutations()[0]).encrypted_value)).toBe(
"s3cret-plaintext",
);
expect(logs.join("\n")).not.toContain("s3cret-plaintext");
expect([...annotations, ...logs].join("\n")).not.toContain("s3cret-plaintext");
expect(logs).toContain('actions_secrets: created secret "DEPLOY_TOKEN"');
});
});
Expand Down
12 changes: 2 additions & 10 deletions src/sections/branches/branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { executePlan } from "../../../src/engine/execute.js";
import { SectionSelection } from "../../../src/engine/section-selection.js";
import { snapshotRepository } from "../../../src/engine/snapshot.js";
import type { GithubClient } from "../../../src/github/api.js";
import { type Io, maskRegistry } from "../../../src/io.js";
import {
type PlannedOp,
planContext,
Expand All @@ -13,6 +12,7 @@ import { allEndpoints, allGraphqlOps } from "../../../src/sections/registry.js";
import type { MustBeNever } from "../../../src/types.js";
import { buildState, type LiveState } from "../../../test/e2e/mock/state.js";
import type { Json } from "../../../test/e2e/mock/support.js";
import { captureIo } from "../../../test/io/capture.js";
import { MockApi } from "../../../test/mock-api.js";
import { registryFake } from "../../../test/sections/fragment-fake.js";
import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js";
Expand Down Expand Up @@ -1753,15 +1753,7 @@ describe("branches snapshot", () => {
'Repository with the given name (a 404 here can also mean the resource does not exist). To fix, grant "Administration" ' +
"(read and write) under the PAT's Repository permissions",
);
const annotations: string[] = [];
const io: Io = {
annotate: (level, message) => annotations.push(`${level}: ${message}`),
log: () => {},
debug: () => {},
summary: () => {},
output: () => {},
...maskRegistry(() => {}),
};
const { io, annotations } = captureIo();
const result = await snapshotRepository(
api,
{
Expand Down
3 changes: 1 addition & 2 deletions test/action/action-yml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { OUTPUT_DECLS } from "../../src/action/io.js";
import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js";
import { RUN_RESULTS } from "../../src/engine/outcome.js";
import { FILTER_INPUTS, INPUT_DECLS, type InputDecl } from "../../src/flows/inputs.js";

const ROOT = join(import.meta.dir, "..", "..");
import { ROOT } from "../root.js";

interface ActionYml {
name: string;
Expand Down
97 changes: 45 additions & 52 deletions test/action/io.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { actionsIo } from "../../src/action/io.js";
import { writeSummary } from "../../src/flows/summary.js";
import { ROOT } from "../root.js";
import { withTempDir } from "../temp-dir.js";

/** A static import, a re-export, a dynamic import(), or a require() all quote the specifier; a comment mentioning it bare does not. */
function namesActionsCore(source: string): boolean {
Expand All @@ -25,7 +26,7 @@ describe("the Io port boundary", () => {

test("only src/action/ names @actions/core", () => {
// Every other layer reaches the runner through the Io port, so redaction and capture have one place to stand.
const srcDir = join(import.meta.dir, "..", "..", "src");
const srcDir = join(ROOT, "src");
const files = readdirSync(srcDir, { recursive: true }) as string[];
const offenders: string[] = [];
let scanned = 0;
Expand All @@ -50,7 +51,6 @@ describe("actionsIo", () => {
summary: process.env.GITHUB_STEP_SUMMARY,
output: process.env.GITHUB_OUTPUT,
};
const dirs: string[] = [];
afterEach(() => {
for (const [key, value] of [
["GITHUB_STEP_SUMMARY", saved.summary],
Expand All @@ -62,56 +62,49 @@ describe("actionsIo", () => {
process.env[key] = value;
}
}
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
const scratch = (): string => {
const dir = mkdtempSync(join(tmpdir(), "sac-io-"));
dirs.push(dir);
return dir;
};
test("summary appends each block with one trailing newline, and skips when the runner file is unset", () =>
withTempDir("sac-io-", (dir) => {
const file = join(dir, "summary.md");
delete process.env.GITHUB_STEP_SUMMARY;
actionsIo.summary("dropped");
process.env.GITHUB_STEP_SUMMARY = file;
writeSummary(
actionsIo,
{ outcomes: [{ key: "repository", status: "drift", detail: ["has_wiki: true -> false"] }] },
"check",
"drift",
);
actionsIo.summary("## second block");
expect(readFileSync(file, "utf8")).toBe(
[
"## github-settings-as-code (check)",
"",
"| Section | Status | Detail |",
"|---|---|---|",
"| repository | :warning: drift | has_wiki: true -> false |",
"## second block",
"",
].join("\n"),
);
}));

test("summary appends each block with one trailing newline, and skips when the runner file is unset", () => {
const file = join(scratch(), "summary.md");
delete process.env.GITHUB_STEP_SUMMARY;
actionsIo.summary("dropped");
process.env.GITHUB_STEP_SUMMARY = file;
writeSummary(
actionsIo,
{ outcomes: [{ key: "repository", status: "drift", detail: ["has_wiki: true -> false"] }] },
"check",
"drift",
);
actionsIo.summary("## second block");
expect(readFileSync(file, "utf8")).toBe(
[
"## github-settings-as-code (check)",
"",
"| Section | Status | Detail |",
"|---|---|---|",
"| repository | :warning: drift | has_wiki: true -> false |",
"## second block",
"",
].join("\n"),
);
});

test("output writes the runner's output file only when it is set", () => {
// The runner creates the file; @actions/core refuses to append to a missing one.
const file = join(scratch(), "output.txt");
writeFileSync(file, "");
delete process.env.GITHUB_OUTPUT;
actionsIo.output("result", "dropped");
// @ts-expect-error a misspelled output name fails to compile at the port
actionsIo.output("reslut", "dropped");
process.env.GITHUB_OUTPUT = file;
actionsIo.output("result", "clean");
// @actions/core writes outputs in heredoc form: name<<DELIM / value / DELIM
const written = readFileSync(file, "utf8");
expect(written).toMatch(/^result<<[^\n]+\nclean\n[^\n]+\n$/);
expect(written).not.toContain("dropped");
});
test("output writes the runner's output file only when it is set", () =>
withTempDir("sac-io-", (dir) => {
// The runner creates the file; @actions/core refuses to append to a missing one.
const file = join(dir, "output.txt");
writeFileSync(file, "");
delete process.env.GITHUB_OUTPUT;
actionsIo.output("result", "dropped");
// @ts-expect-error a misspelled output name fails to compile at the port
actionsIo.output("reslut", "dropped");
process.env.GITHUB_OUTPUT = file;
actionsIo.output("result", "clean");
// @actions/core writes outputs in heredoc form: name<<DELIM / value / DELIM
const written = readFileSync(file, "utf8");
expect(written).toMatch(/^result<<[^\n]+\nclean\n[^\n]+\n$/);
expect(written).not.toContain("dropped");
}));

test("mask registers the value in the readable registry", () => {
actionsIo.mask("o/private");
Expand Down
Loading