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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
59 changes: 47 additions & 12 deletions .github/scripts/changed-sections.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* The diff-aware section selector for the PR e2e smoke job: a PR touching one section runs that section's scenarios
* and fuzz rather than the whole corpus, and a PR touching nothing settings-related skips the smoke steps. A path
* under src/sections/, test/src/sections/, or docs/sections/ that no rule recognizes throws, so a new file cannot
* silently skip them.
* under src/sections/ or docs/sections/, or in a directory under test/sections/, that no rule recognizes throws, so a
* new file cannot silently skip them.
*
* src/sections/<key>/... -> <key>, whatever the file
* test/src/sections/<key>/... -> <key> (the section's tests, mock, generators, scenarios)
* test/sections/<key>/... -> <key> (the section's tests, mock, generators, scenarios)
* test/sections/<file>, test/sections/snapshot-rows/ -> none (the cross-section suites and their fixtures; a flat
* file named after a section is misplaced and throws)
* docs/sections/<key>.docs.yml -> <key>; shared.docs.yml and docs/schema.docs.yml select none
* src/sections/shared/<file>.ts -> the sections that transitively import it (deriveSharedFanOut)
* contract/, registry.ts, the engine, the schema, the e2e harness -> every section
Expand Down Expand Up @@ -296,24 +298,57 @@ function sectionsForSectionsPath(
);
}

const TEST_MIRROR_PREFIX = "test/src/sections/";
const TEST_SECTIONS_PREFIX = "test/sections/";
/** The fixture directories beside the section mirrors under test/sections/: cross-section, like the flat suites. */
const CROSS_SECTION_TEST_DIRS: ReadonlySet<string> = new Set(["snapshot-rows"]);
const SECTION_DOCS_PREFIX = "docs/sections/";
/** The document root's schema prose, gated by build:check like the docs registry. */
const ROOT_DOCS_FILE = "docs/schema.docs.yml";
/** The shared factories' schema prose: it belongs to no one section, and build:check gates it too. */
const SHARED_DOCS_FILE = `${SECTION_DOCS_PREFIX}shared.docs.yml`;

/** A section's tests, mock, generators, and scenarios mirror it under test/src/sections/<key>/; a deleted scenario can
* leave a route cold, so the section still runs. Anything else under the mirror root throws, as under src/sections/. */
function sectionsForTestMirrorPath(path: string): SectionKey[] {
const rest = path.slice(TEST_MIRROR_PREFIX.length);
/** labels.test.ts and labels-schema.test.ts, but not list-section.test.ts: the stem is a key or its dashed slug,
* alone or before a dash or underscore. */
function isSectionNamedFile(name: string): boolean {
const stem = name.split(".")[0] ?? "";
return SECTION_KEYS.some((key) => {
const slug = key.replaceAll("_", "-");
return (
stem === key ||
stem === slug ||
stem.startsWith(`${key}-`) ||
stem.startsWith(`${key}_`) ||
stem.startsWith(`${slug}-`) ||
stem.startsWith(`${slug}_`)
);
Comment thread
Vivswan marked this conversation as resolved.
});
}

/** A section's tests, mock, generators, and scenarios mirror it under test/sections/<key>/; a deleted scenario can
* leave a route cold, so the section still runs. The flat files beside those directories are the cross-section suites
* and select none, as does a fixture directory in CROSS_SECTION_TEST_DIRS. A flat file named after a section belongs
* in its directory, so it throws unless deleted (the fold of a flat suite into its directory is such a deletion, and
* the added file selects the section). */
function sectionsForTestSectionsPath({ path, deleted }: ChangedFile): SectionKey[] {
const rest = path.slice(TEST_SECTIONS_PREFIX.length);
const slash = rest.indexOf("/");
const dir = slash < 0 ? "" : rest.slice(0, slash);
if (slash < 0) {
if (!deleted && isSectionNamedFile(rest)) {
throw new Error(
`changed-sections: ${path} matches no selector rule; a file named after a section lives in ${TEST_SECTIONS_PREFIX}<key>/, not beside the cross-section suites`,
);
}
return [];
}
const dir = rest.slice(0, slash);
if (SECTION_KEY_SET.has(dir)) {
return [dir as SectionKey];
}
if (CROSS_SECTION_TEST_DIRS.has(dir)) {
return [];
}
throw new Error(
`changed-sections: ${path} matches no selector rule; ${TEST_MIRROR_PREFIX} holds only the per-section <key>/ directories, each spelling its SectionKey verbatim`,
`changed-sections: ${path} matches no selector rule; a directory under ${TEST_SECTIONS_PREFIX} spells its SectionKey verbatim (or is named in CROSS_SECTION_TEST_DIRS if it holds cross-section fixtures)`,
);
}

Expand Down Expand Up @@ -341,8 +376,8 @@ function sectionsForPath(
if (path.startsWith("src/sections/")) {
return sectionsForSectionsPath(file, sharedFanOut);
}
if (path.startsWith(TEST_MIRROR_PREFIX)) {
return sectionsForTestMirrorPath(path);
if (path.startsWith(TEST_SECTIONS_PREFIX)) {
return sectionsForTestSectionsPath(file);
}
if (path.startsWith(SECTION_DOCS_PREFIX)) {
return sectionsForSectionDocsPath(path);
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Code is the source of truth: this section holds only the rules and the decisions
### Decisions a reader would otherwise reverse

- A flat `src/sections/<key>/` directory means repository scope, permanently; org/user scopes arrive as sibling scope directories with their own document, keys, and registry (the ":" reservation in `src/sections/registry.ts`).
- The section directory is the unit of work: the compiler flags every forgotten registration step, its tests and e2e fragments mirror it under `test/src/sections/<key>/`, and its prose is `docs/sections/<key>.docs.yml`.
- The section directory is the unit of work: the compiler flags every forgotten registration step, its tests and e2e fragments mirror it under `test/sections/<key>/`, and its prose is `docs/sections/<key>.docs.yml`.
- `src/upstream-gaps/` holds one file per GitHub feature an upstream artifact lags; `gap.ts` states how each kind graduates.
- The layered fold is a CSS-like cascade in which `null` is a value: GitHub's EMPTY or OFF state, not CSS `unset`. docs/operate/layering.md owns the rules, including the two CSS has no analogue for (an undeclared key keeps GitHub's current value; `_remove: true` drops a keyed entry).

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all
- 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 under `test/`, mirroring `src/`: a section's unit tests, `mock.ts`, `generators.ts`, and `scenarios/` sit in `test/src/sections/<key>/`; `src/` holds code only.
- Tests live under `test/`, mirroring `src/`: a section's unit tests, `mock.ts`, `generators.ts`, and `scenarios/` sit in `test/sections/<key>/`; `src/` holds code only.

## Tests

Expand All @@ -36,7 +36,7 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all
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.

- `bun run test:e2e` runs the curated scenario corpus.
- Every section ships the standard scenario set under `test/src/sections/<key>/scenarios/`, named after the section's dashed key: `<slug>-apply-converges`, `<slug>-check-drift` (a section with a planning read), `<slug>-snapshot-roundtrip` (a section with snapshot()), and for a section under the undeclared policy `<slug>-undeclared-delete` and `<slug>-undeclared-keep-note`; `test/sections/scenario-set.test.ts` derives the set from the registry.
- Every section ships the standard scenario set under `test/sections/<key>/scenarios/`, named after the section's dashed key: `<slug>-apply-converges`, `<slug>-check-drift` (a section with a planning read), `<slug>-snapshot-roundtrip` (a section with snapshot()), and for a section under the undeclared policy `<slug>-undeclared-delete` and `<slug>-undeclared-keep-note`; `test/sections/scenario-set.test.ts` derives the set from the registry.
- `bun run fuzz` runs seeded property fuzzing: random scenarios, each checked against an oracle that predicts the outcome class from the token mask, policy, and mode.
- The mock serves the section endpoints plus the core routes the action calls outside the sections. A request that matches no registered route fails loudly; the mock never invents a response.
- PR CI runs the sections a pull request changed. The nightly workflow's `e2e` job runs the full corpus and files a red night under the `nightly-failure` issue; the fuzz nightly runs the full fuzz and files under `fuzz-nightly` with a replay command.
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ flowchart LR
- The one live-axis knob is `_undeclared`: what happens to a live resource the file does not declare. A knobbed list section's wrapper sets it; a file's top-level `_undeclared` sets it for every knobbed list section of that file, and the run input `undeclared` for every file; the section's default applies where none is set. `environments`, `branches`, and `workflows` apply no policy and refuse the knob ([Undeclared policy](undeclared-policy.md)).
- Re-running an apply rewrites nothing the engine can read back, and a check right after it reads clean. Writes whose value GitHub does not read back recur by design: `interaction_limits` re-arms its expiry, every declared secret is re-sealed, and the Git LFS toggle and `check_suite_preferences` are re-sent on every apply.

Demonstrated by: [test/e2e/scenarios/apply-idempotent-unconditional.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/apply-idempotent-unconditional.yml), [test/src/sections/actions_variables/scenarios/actions-variables-undeclared-keep-note.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/src/sections/actions_variables/scenarios/actions-variables-undeclared-keep-note.yml), [test/src/sections/actions_secrets/scenarios/actions-secrets-undeclared-delete.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/src/sections/actions_secrets/scenarios/actions-secrets-undeclared-delete.yml).
Demonstrated by: [test/e2e/scenarios/apply-idempotent-unconditional.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/apply-idempotent-unconditional.yml), [test/sections/actions_variables/scenarios/actions-variables-undeclared-keep-note.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/sections/actions_variables/scenarios/actions-variables-undeclared-keep-note.yml), [test/sections/actions_secrets/scenarios/actions-secrets-undeclared-delete.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/sections/actions_secrets/scenarios/actions-secrets-undeclared-delete.yml).

## The mode ladder

Expand Down Expand Up @@ -140,7 +140,7 @@ The success path mints the input a planner accepts: a section's `plan()` takes t

Secret references are the exception. The `$NAME` syntax is judged per section when the run starts, because the verdict needs the document's provenance ([Trust and provenance](#trust-and-provenance)).

Demonstrated by: [test/engine/validate.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/validate.test.ts), [test/engine/orchestrate.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/orchestrate.test.ts), [test/src/sections/interaction_limits/scenarios/interaction-limits-invalid-values-and-unknown-key-rejected.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/src/sections/interaction_limits/scenarios/interaction-limits-invalid-values-and-unknown-key-rejected.yml).
Demonstrated by: [test/engine/validate.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/validate.test.ts), [test/engine/orchestrate.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/orchestrate.test.ts), [test/sections/interaction_limits/scenarios/interaction-limits-invalid-values-and-unknown-key-rejected.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/sections/interaction_limits/scenarios/interaction-limits-invalid-values-and-unknown-key-rejected.yml).

## The layering fold

Expand Down Expand Up @@ -402,4 +402,4 @@ graph TD
```
<!-- END GENERATED: architecture-map -->

Each section's e2e harness fragments (`mock.ts`, `generators.ts`) are test code under `test/src/sections/<key>/`, outside the map.
Each section's e2e harness fragments (`mock.ts`, `generators.ts`) are test code under `test/sections/<key>/`, outside the map.
2 changes: 1 addition & 1 deletion test/e2e/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ describe("harness identity constants", () => {
// class is banned at the import boundary: a fragment always has the owning state in scope.
const offenders: string[] = [];
let fragments = 0;
for await (const file of new Bun.Glob("test/src/sections/*/mock.ts").scan(ROOT)) {
for await (const file of new Bun.Glob("test/sections/*/mock.ts").scan(ROOT)) {
fragments++;
const text = await Bun.file(join(ROOT, file)).text();
if (/from "[^"]*\/e2e\/constants\.js"/.test(text)) {
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/gen-support.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* The leaf seam shared by the per-section generator fragments (test/src/sections/<key>/generators.ts) and their aggregator
* The leaf seam shared by the per-section generator fragments (test/sections/<key>/generators.ts) and their aggregator
* (test/e2e/generators.ts). Like mock/support.ts, it imports no fragment and no aggregator, so the fragments depend on it without a cycle.
*/

Expand Down
40 changes: 20 additions & 20 deletions test/e2e/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,26 @@ import { allEndpoints, allGraphqlOps, SECTIONS } from "../../src/sections/regist
import { compileFailure } from "../../src/sections/secret_scanning_custom_patterns/compilable-form.js";
import { MAX_VARIABLE_VALUE_BYTES } from "../../src/sections/shared/schema-helpers.js";
import type { MustBeNever } from "../../src/types.js";
import { genActions } from "../src/sections/actions/generators.js";
import { autolinksWitness, genAutolinks } from "../src/sections/autolinks/generators.js";
import { FUZZ_DEPLOYMENT_ENVIRONMENTS, genBranches } from "../src/sections/branches/generators.js";
import { genCheckSuitePreferences } from "../src/sections/check_suite_preferences/generators.js";
import { genCodeQuality } from "../src/sections/code_quality_setup/generators.js";
import { genCodeScanning } from "../src/sections/code_scanning_default_setup/generators.js";
import { genCollaborators, genInvitationsState } from "../src/sections/collaborators/generators.js";
import { genCustomProperties } from "../src/sections/custom_properties/generators.js";
import { deployKeysWitness, genDeployKeys } from "../src/sections/deploy_keys/generators.js";
import { genEnvironments } from "../src/sections/environments/generators.js";
import { genInteractionLimits } from "../src/sections/interaction_limits/generators.js";
import { genLabels, labelsWitness } from "../src/sections/labels/generators.js";
import { genMilestones, milestonesWitness } from "../src/sections/milestones/generators.js";
import { genPages } from "../src/sections/pages/generators.js";
import { genRepository } from "../src/sections/repository/generators.js";
import { genRulesets, PULL_REQUEST_PARAMETERS } from "../src/sections/rulesets/generators.js";
import { genSecretScanningPatterns } from "../src/sections/secret_scanning_custom_patterns/generators.js";
import { genTeams } from "../src/sections/teams/generators.js";
import { genWebhooks } from "../src/sections/webhooks/generators.js";
import { genWorkflows } from "../src/sections/workflows/generators.js";
import { genActions } from "../sections/actions/generators.js";
import { autolinksWitness, genAutolinks } from "../sections/autolinks/generators.js";
import { FUZZ_DEPLOYMENT_ENVIRONMENTS, genBranches } from "../sections/branches/generators.js";
import { genCheckSuitePreferences } from "../sections/check_suite_preferences/generators.js";
import { genCodeQuality } from "../sections/code_quality_setup/generators.js";
import { genCodeScanning } from "../sections/code_scanning_default_setup/generators.js";
import { genCollaborators, genInvitationsState } from "../sections/collaborators/generators.js";
import { genCustomProperties } from "../sections/custom_properties/generators.js";
import { deployKeysWitness, genDeployKeys } from "../sections/deploy_keys/generators.js";
import { genEnvironments } from "../sections/environments/generators.js";
import { genInteractionLimits } from "../sections/interaction_limits/generators.js";
import { genLabels, labelsWitness } from "../sections/labels/generators.js";
import { genMilestones, milestonesWitness } from "../sections/milestones/generators.js";
import { genPages } from "../sections/pages/generators.js";
import { genRepository } from "../sections/repository/generators.js";
import { genRulesets, PULL_REQUEST_PARAMETERS } from "../sections/rulesets/generators.js";
import { genSecretScanningPatterns } from "../sections/secret_scanning_custom_patterns/generators.js";
import { genTeams } from "../sections/teams/generators.js";
import { genWebhooks } from "../sections/webhooks/generators.js";
import { genWorkflows } from "../sections/workflows/generators.js";
import { ADMIN_SLUG } from "./constants.js";
import {
DEFAULT_LAYERING_DIRECTIVE,
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/mock/graphql-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ describe("GraphQL response guard and chaos", () => {
describe("assertGraphqlHandlerCompleteness", () => {
test("both drift directions fail loudly", () => {
expect(() => assertGraphqlHandlerCompleteness(OPS, {})).toThrow(
/GraphQL operations with no mock handler: \[repository\.gNodeId \(add it in test\/src\/sections\/repository\/mock\.ts/,
/GraphQL operations with no mock handler: \[repository\.gNodeId \(add it in test\/sections\/repository\/mock\.ts/,
);
expect(() => assertGraphqlHandlerCompleteness({}, HANDLERS)).toThrow(
/GraphQL handlers naming no declared operation/,
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/mock/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const GRAPHQL_HANDLERS: Record<string, GraphqlHandler> = sectionGraphqlHa

function missingHandlerPointer(missing: Array<[string, { section: string }]>): string {
return missing
.map(([key, { section }]) => `${key} (add it in test/src/sections/${section}/mock.ts)`)
.map(([key, { section }]) => `${key} (add it in test/sections/${section}/mock.ts)`)
.sort()
.join(", ");
}
Expand Down
Loading
Loading