Skip to content

Commit 4debbed

Browse files
authored
feat: add mode: merge with a layered settings-file (#104)
## What this adds ```yaml - uses: Vivswan/github-settings-as-code@v3 with: mode: merge merged-file: ${{ runner.temp }}/merged.yml settings-file: | # low to high fleet/baseline.yml fleet/bun.yml .github/settings.yml fleet/override.yml - uses: Vivswan/github-settings-as-code@v3 with: mode: apply token: ${{ secrets.SETTINGS_TOKEN }} settings-file: ${{ runner.temp }}/merged.yml ``` ```text $ bun test test/engine/layers.test.ts test/action/run.test.ts mergeLayers([fleet, bun, repo, override], { layering: "merge" }) labels bug, enhancement + e2e-fuzz -> bug, enhancement, e2e-fuzz (union by name) rulesets main {deletion, nff} + main {sigs} -> main {deletion, nff, sigs} (rules append by type) rulesets main {bypass_actors: [5]} + main {bypass: null} -> main without bypass_actors (null deletes, notice) pages {build_type: workflow} + pages: null -> removed (notice) labels [...] + {_layering: replace, entries: [...]} -> the higher list alone mode: merge writes the validated document, makes no API call, reads no token; result: merged ``` ## How Three commits, engine, action, then the value-free refusals: - `src/engine/layers.ts`: the pure fold. Objects merge key by key; a higher scalar, array, or tagged value replaces; a higher `null` at any depth deletes what a lower layer declared. The knobbed list sections union their entries by key under `merge` when their section module declares a `layering` (labels by case-folded name, rulesets by name with `rules` nested by `type`); every other list replaces. `_layering: merge|replace` on a wrapper or at a document's top level overrides the run default for that section or file and is consumed. Refusals name the layer: mis-shaped sections, entries without their key, duplicate keys within one list in one layer, a `merge` directive on a section without keyed layering, reference cycles. - `KeyedListLayering` on `SectionMeta`, typed so only knobbed sections declare it. Top-level wrappers accept `_layering`; the nested environment lists reject it. - Action: `settings-file` is an ordered list in `mode: merge` only (one path in apply/check, a list there is an input error); new inputs `merged-file` (required in merge mode) and `layering` (run-wide default). `mode` is parsed before the token requirement, so merge needs no token and constructs no client. Each layer is validated standalone on its null-stripped view, folded, and the result validated again before it is written. The document schema declares the top-level `_layering` directive. - Refusals and notices never print a settings value. Merge mode has no `private-repos` context, so a shape refusal that echoed a label name or a rule type could put a private repository's settings into a public log. Every message goes through one `describeRefusal` over a `Problem` union and names only the layer, the key path with numeric indices, and the problem kind: `rulesets[0].rules[0] and rulesets[0].rules[1] both claim one type; each type belongs to one entry within a layer`, `rulesets[0].rules[0] must be a mapping; got a string`. - e2e: `settings_layers` and `expect.merged` in the scenario schema, the runner writes layer files and compares the merged document; three scenarios cover the union with a null opt-out, a section-level replace, and a file-level directive. ## Proof - `bun run check` green (2418 tests); `bun run test:e2e` 215/215. - Engine: four codex rounds, the lead's gate review (nulls inside list entries, cyclic anchors) fixed with negative controls. Action: seven codex rounds plus the lead's gate review. Refusals: one codex round plus the lead's gate review; both Copilot threads fixed in that commit. - Apply and check are unchanged: one document per target. BREAKING CHANGE: a settings-file path may no longer contain a comma or a newline in any mode (they are the list separators mode: merge splits on); apply and check reject such a path with an input error naming the rule. Rename the file.
1 parent 09d6c3f commit 4debbed

192 files changed

Lines changed: 9104 additions & 1779 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/arch-lint.ts‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* The architecture lint (bun run lint:arch): the imports between the layers
3+
* of src/ are exactly the edges architecture.yml declares - an undeclared
4+
* edge and a stale allowance both fail. gen-docs.ts renders the same
5+
* declaration as the module map. dependency-cruiser was the intended tool but
6+
* cannot cruise this repository: it needs the TypeScript compiler API, which
7+
* the pinned typescript 7 no longer ships, so it resolves nothing.
8+
*
9+
* An edge is any way one file names another: the runtime loads the
10+
* changed-sections scanner reads, plus type-only imports, re-exports, and
11+
* `import("./x.js").T` and `import X = require("./x.js")` in type positions.
12+
*/
13+
14+
import { readdirSync, readFileSync } from "node:fs";
15+
import { join, relative } from "node:path";
16+
import { type Node, parseSync } from "oxc-parser";
17+
import { parse as parseYaml } from "yaml";
18+
import { resolveImport, scanImports } from "./changed-sections.js";
19+
20+
export const ARCHITECTURE_PATH = "architecture.yml";
21+
22+
export interface Architecture {
23+
/** layer -> the src/ paths it owns (a `/` suffix means a directory). */
24+
readonly layers: Readonly<Record<string, readonly string[]>>;
25+
readonly exclude: readonly string[];
26+
/** from -> the layers it may import. */
27+
readonly edges: Readonly<Record<string, readonly string[]>>;
28+
}
29+
30+
export function readArchitecture(root: string): Architecture {
31+
return parseYaml(readFileSync(join(root, ARCHITECTURE_PATH), "utf8")) as Architecture;
32+
}
33+
34+
/** The layer owning a repo-relative path, or undefined. */
35+
function layerOf(arch: Architecture, path: string): string | undefined {
36+
return Object.entries(arch.layers).find(([, paths]) =>
37+
paths.some((owned) => (owned.endsWith("/") ? path.startsWith(owned) : path === owned)),
38+
)?.[0];
39+
}
40+
41+
function* nodesOf(value: unknown): Generator<Node> {
42+
if (Array.isArray(value)) {
43+
for (const item of value) {
44+
yield* nodesOf(item);
45+
}
46+
} else if (typeof value === "object" && value !== null) {
47+
if ("type" in value && typeof value.type === "string") {
48+
yield value as Node;
49+
}
50+
for (const child of Object.values(value)) {
51+
yield* nodesOf(child);
52+
}
53+
}
54+
}
55+
56+
/** Every relative specifier `text` names, runtime and type-level alike. */
57+
export function importSpecifiers(text: string, file: string): string[] {
58+
const { program, module } = parseSync(file, text);
59+
const typeLevel = [...nodesOf(program)].flatMap((node) => {
60+
const source =
61+
node.type === "TSImportType"
62+
? node.source
63+
: node.type === "TSExternalModuleReference"
64+
? node.expression
65+
: undefined;
66+
return source?.type === "Literal" && typeof source.value === "string" ? [source.value] : [];
67+
});
68+
const all = new Set([
69+
...scanImports(text, file),
70+
...module.staticImports.map((entry) => entry.moduleRequest.value),
71+
...module.staticExports.flatMap((entry) =>
72+
entry.entries.flatMap((item) => (item.moduleRequest ? [item.moduleRequest.value] : [])),
73+
),
74+
...typeLevel,
75+
]);
76+
return [...all].filter((specifier) => /^\.\.?\//.test(specifier));
77+
}
78+
79+
/**
80+
* The lint verdict for `root`: an import between layers the declaration lacks
81+
* (naming both files), a declared edge no file draws, or a cruised file outside
82+
* every layer. Empty means the declaration is exactly the tree.
83+
*/
84+
export function lintArchitecture(root: string, arch = readArchitecture(root)): string[] {
85+
const excluded = arch.exclude.map((pattern) => new Bun.Glob(pattern));
86+
const drawn = new Map<string, string[]>();
87+
const problems: string[] = [];
88+
for (const entry of readdirSync(join(root, "src"), { recursive: true, encoding: "utf8" })) {
89+
const file = join("src", entry);
90+
if (!file.endsWith(".ts") || excluded.some((glob) => glob.match(file))) {
91+
continue;
92+
}
93+
const from = layerOf(arch, file);
94+
if (from === undefined) {
95+
problems.push(`${file} belongs to no layer in ${ARCHITECTURE_PATH}`);
96+
continue;
97+
}
98+
const absolute = join(root, file);
99+
for (const specifier of importSpecifiers(readFileSync(absolute, "utf8"), absolute)) {
100+
const target = relative(root, resolveImport(absolute, specifier));
101+
const to = layerOf(arch, target);
102+
if (to === undefined) {
103+
problems.push(
104+
`${target} (imported by ${file}) belongs to no layer in ${ARCHITECTURE_PATH}`,
105+
);
106+
} else if (to !== from) {
107+
const key = `${from} -> ${to}`;
108+
drawn.set(key, [...(drawn.get(key) ?? []), `${file} -> ${target}`]);
109+
}
110+
}
111+
}
112+
const declared = new Set(
113+
Object.entries(arch.edges).flatMap(([from, targets]) =>
114+
targets.map((to) => `${from} -> ${to}`),
115+
),
116+
);
117+
for (const [key, sites] of [...drawn].sort()) {
118+
if (!declared.has(key)) {
119+
problems.push(`forbidden import ${key}: ${sites.join(", ")}; move it or declare the edge`);
120+
}
121+
}
122+
for (const key of [...declared].sort()) {
123+
if (!drawn.has(key)) {
124+
problems.push(
125+
`stale allowance ${key}: no file draws it; remove it from ${ARCHITECTURE_PATH}`,
126+
);
127+
}
128+
}
129+
return problems;
130+
}
131+
132+
/** The module map over the DECLARED edges; a hyphen in a layer name is edge syntax to mermaid. */
133+
export function renderArchitectureMermaid(arch: Architecture): string {
134+
const id = (layer: string): string => layer.replace(/-/g, "_");
135+
return [
136+
"graph TD",
137+
...Object.entries(arch.layers).map(([name, paths]) => ` ${id(name)}["${paths.join("<br>")}"]`),
138+
...Object.entries(arch.edges).flatMap(([from, targets]) =>
139+
targets.map((to) => ` ${id(from)} --> ${id(to)}`),
140+
),
141+
].join("\n");
142+
}
143+
144+
if (import.meta.main) {
145+
const problems = lintArchitecture(join(import.meta.dir, "..", ".."));
146+
if (problems.length > 0) {
147+
console.error(`lint:arch: ${problems.length} problem(s)\n ${problems.join("\n ")}`);
148+
process.exit(1);
149+
}
150+
console.log(`lint:arch: src/ imports match ${ARCHITECTURE_PATH}`);
151+
}

‎.github/scripts/changed-sections.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,13 @@ function assertNoComputedImports(text: string, file: string): void {
154154
const [error] = errors;
155155
if (error) {
156156
throw new Error(
157-
`changed-sections: ${file}:${lineOf(error.labels[0]?.start ?? 0)} does not parse: ${error.message}`,
157+
`${file}:${lineOf(error.labels[0]?.start ?? 0)} does not parse: ${error.message}`,
158158
);
159159
}
160160
for (const node of nodesOf(program)) {
161161
if (isComputedModuleLoad(node)) {
162162
throw new Error(
163-
`changed-sections: ${file}:${lineOf(node.start)} loads a module through a computed specifier, which the import graph cannot follow - use a string literal`,
163+
`${file}:${lineOf(node.start)} loads a module through a computed specifier, which the import graph cannot follow - use a string literal`,
164164
);
165165
}
166166
}
@@ -169,7 +169,9 @@ function assertNoComputedImports(text: string, file: string): void {
169169
/**
170170
* The relative specifiers `file` loads at runtime. Type-only imports are not
171171
* edges (erased from the bundle) and neither are bare specifiers; a
172-
* computed specifier throws rather than dropping an edge.
172+
* computed specifier throws rather than dropping an edge. Shared with
173+
* arch-lint.ts, as is resolveImport, so their errors name the file and not
174+
* this tool.
173175
*/
174176
export function scanImports(text: string, file: string): string[] {
175177
assertNoComputedImports(text, file);
@@ -194,7 +196,7 @@ export function resolveImport(importer: string, specifier: string): string {
194196
}
195197
}
196198
throw new Error(
197-
`changed-sections: ${importer} imports "${specifier}", which resolves to no file (tried ${candidates.join(" and ")})`,
199+
`${importer} imports "${specifier}", which resolves to no file (tried ${candidates.join(" and ")})`,
198200
);
199201
}
200202

‎.github/scripts/gen-docs.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
// Emits the generated regions of COVERAGE.md, the sections and inputs reference pages, and the two
2-
// pages carrying the token-form link (README.md and the getting-started guide), each between
3-
// `<!-- BEGIN/END GENERATED: <name> -->` markers (build:docs): the Sections table, the `result`
4-
// list, the token-form link definition, and COVERAGE's whole body. Authored prose from the docs
5-
// registry + coverage-data.
1+
// Emits the generated regions of COVERAGE.md, the sections, inputs, and architecture reference
2+
// pages, and the two pages carrying the token-form link (README.md and the getting-started
3+
// guide), each between `<!-- BEGIN/END GENERATED: <name> -->` markers (build:docs): the Sections
4+
// table, the `result` list, the token-form link definition, COVERAGE's whole body, and the module
5+
// map rendered from architecture.yml. Authored prose from the docs registry + coverage-data.
66
import { readFileSync, writeFileSync } from "node:fs";
77
import { join } from "node:path";
8+
import { MERGE_RESULT } from "../../src/action/deliver.js";
89
import { REPO_RESULTS, type RepoResult } from "../../src/engine/orchestrate.js";
910
import type { SectionDocs } from "../../src/sections/contract/docs.js";
1011
import {
@@ -17,6 +18,7 @@ import { RESOURCE_SLUGS } from "../../src/sections/contract/permissions.js";
1718
import { DOCS } from "../../src/sections/docs-registry.js";
1819
import { SECTIONS } from "../../src/sections/registry.js";
1920
import type { UndeclaredPolicy } from "../../src/types.js";
21+
import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js";
2022
import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js";
2123
import {
2224
escapeRe,
@@ -114,7 +116,7 @@ const TABLE_HEADER =
114116
/** The Sections table, one row per section in the given order; a section without docs throws. */
115117
export function renderSectionsTable(
116118
sections: readonly SectionsTableRow[],
117-
docs: Readonly<Record<string, Pick<SectionDocs, "readme">>>,
119+
docs: Readonly<Record<string, Pick<SectionDocs, "sections_table">>>,
118120
): string {
119121
const rows = sections.map((section) => {
120122
const doc = docs[section.key];
@@ -123,10 +125,10 @@ export function renderSectionsTable(
123125
}
124126
return [
125127
`\`${section.key}\``,
126-
cell(doc.readme.endpoints, `the ${section.key} Endpoints cell`),
128+
cell(doc.sections_table.endpoints, `the ${section.key} Endpoints cell`),
127129
cell(renderPatCell(sectionGrant(section)), `the ${section.key} PAT permission cell`),
128130
UNDECLARED_DEFAULT_DISPLAY[section.undeclaredDefault],
129-
cell(doc.readme.notes, `the ${section.key} Notes cell`),
131+
cell(doc.sections_table.notes, `the ${section.key} Notes cell`),
130132
];
131133
});
132134
return [TABLE_HEADER, ...rows.map((cells) => `| ${cells.join(" | ")} |`)].join("\n");
@@ -255,8 +257,10 @@ const RESULT_DISPLAY: Record<RepoResult, "any mode" | "multi-repo only"> = {
255257
/** The outputs enumeration's fixed phrases, as rendered and as the region shape expects them. */
256258
const WORST_OF = "; worst-of across targets in multi-repo mode";
257259
const CAN_ALSO_APPEAR = " can also appear";
260+
/** The merge-mode result, a value outside RepoResult, closes the enumeration. */
261+
const MERGE_ONLY = `; \`${MERGE_RESULT}\` in mode: merge`;
258262

259-
/** The `result` output's value enumeration: the any-mode values, then the multi-repo-only ones. */
263+
/** The `result` output's value enumeration: the any-mode values, the multi-repo-only ones, then the merge result. */
260264
export function renderOutputsList(results: readonly RepoResult[]): string {
261265
const ordered = (Object.keys(RESULT_DISPLAY) as RepoResult[]).filter((value) =>
262266
results.includes(value),
@@ -267,9 +271,9 @@ export function renderOutputsList(results: readonly RepoResult[]): string {
267271
.filter((value) => RESULT_DISPLAY[value] === "multi-repo only")
268272
.map(code);
269273
const lead = `${anyMode.join(" / ")}${WORST_OF}`;
270-
return multiOnly.length === 0
271-
? lead
272-
: `${lead}, where ${multiOnly.join(" and ")}${CAN_ALSO_APPEAR}`;
274+
const withMulti =
275+
multiOnly.length === 0 ? lead : `${lead}, where ${multiOnly.join(" and ")}${CAN_ALSO_APPEAR}`;
276+
return `${withMulti}${MERGE_ONLY}`;
273277
}
274278

275279
/** A section operation tagged with its section, as patFormParameters reads it. */
@@ -358,7 +362,7 @@ function outputsListRegion(name: string, heading: string): GeneratedRegion {
358362
name,
359363
placement: { kind: "under-heading", heading },
360364
body: new RegExp(
361-
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${escapeRe(WORST_OF)}(?:, where \x60[a-z]+\x60(?: and \x60[a-z]+\x60)*${escapeRe(CAN_ALSO_APPEAR)})?)?$`,
365+
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${escapeRe(WORST_OF)}(?:, where \x60[a-z]+\x60(?: and \x60[a-z]+\x60)*${escapeRe(CAN_ALSO_APPEAR)})?(?:${escapeRe(MERGE_ONLY)})?)?$`,
362366
),
363367
render: () => renderOutputsList(REPO_RESULTS),
364368
};
@@ -374,13 +378,28 @@ function patUrlRegion(name: string): GeneratedRegion {
374378
};
375379
}
376380

381+
// The module map: a mermaid fence rendered from architecture.yml's layers and edges (the lint
382+
// keeps that declaration equal to the tree), as region `name` under `heading`.
383+
function architectureMapRegion(name: string, heading: string): GeneratedRegion {
384+
return {
385+
name,
386+
placement: { kind: "under-heading", heading },
387+
body: /^\n(?:\x60{3}mermaid\n(?:[^\x60\n][^\n]*\n)*\x60{3}\n)?$/,
388+
render: () =>
389+
`\n\x60\x60\x60mermaid\n${renderArchitectureMermaid(readArchitecture(ROOT))}\n\x60\x60\x60\n`,
390+
};
391+
}
392+
377393
// Every page this generator writes besides COVERAGE.md, keyed by path: the reference tables in
378394
// their homes, and the token-form link definition closing the README and the getting-started guide.
379395
export const PAGE_REGIONS: Readonly<Record<string, readonly GeneratedRegion[]>> = {
380396
"README.md": [patUrlRegion("readme-pat-url")],
381397
"docs/start/getting-started.md": [patUrlRegion("pat-url")],
382398
"docs/reference/sections.md": [sectionsTableRegion("sections-table", "# Sections")],
383399
"docs/reference/inputs.md": [outputsListRegion("outputs-list", "## Outputs")],
400+
"docs/reference/architecture.md": [
401+
architectureMapRegion("architecture-map", "## The module map"),
402+
],
384403
};
385404

386405
// The page at `path` with its regions checked for placement, then regenerated. The result must

‎.github/workflows/checks.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
- uses: ./.github/actions/fetch-test-artifacts
2727
- name: Lint (biome)
2828
run: bun run lint
29+
- name: Lint (architecture)
30+
run: bun run lint:arch
2931
- name: Typecheck
3032
run: bun run typecheck
3133
- name: Dead code (knip)

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings:
3535
<!-- END REPO-PLATFORM MANAGED -->
3636

3737
- `lib/settings.schema.json` is the COMMITTED JSON Schema for settings.yml, generated from the zod slices in `src/sections/<key>/schema.ts` as composed into the settings document by `src/schema.ts`. Each slice is ONE declaration that produces its config type (z.infer), the section's tolerant runtime shape (the section module derives `loosen(<slice>)` itself; the factory-minted families - secrets, variables, the two setup sections, and the list sections on `listSection` - get it from their shared factory in `src/sections/shared/`, which does that derivation once), and its part of the published schema (`.meta({id})` names its definitions; the descriptions come from the `schema:` map in the section's `<key>.docs.yml`, keyed `Definition.field`, which the generator attaches and fails on when a site or a key is unmatched); `src/schema.ts` adds only the document-level wrappers and pins each property to its slice with a lockstep type. Regenerate the schema with `bun run build:schema` after a schema-affecting `src/` change - CI's schema-check job fails when it drifts. `lib/index.js`, the bundled entrypoint the action runs (node24), is NOT committed on main: every workflow that executes the action builds it first (`bun run build:bundle`), the e2e runner builds it to a temp path, and a release builds and commits it on a packaged child of the release commit, which is what every `vX.Y.Z` tag and the moving major point at (main itself stays source-only). `lib/` is exempt from the typography check (third-party unicode in the bundle; the schema descriptions are checked at source) and excluded from [biome](https://biomejs.dev).
38+
- The import layering of `src/` is declared in `architecture.yml` and enforced by `bun run lint:arch`, so a new cross-layer import is a deliberate edit to that file; the module map in `docs/reference/architecture.md` is generated from it.
3839
- The apply/check engine layout: `src/main.ts` is the thin bundled entrypoint; `src/io.ts` is the Io output port (action-layer-free; the action implements it over @actions/core); `src/types.ts` holds the leaf type vocabulary; `src/action/` is the GitHub Actions layer (inputs, settings reading, step summaries, redaction, the single- and multi-repo run flows); `src/engine/` is the per-repo pipeline (orchestrate, validate, merge, diff); `src/github/` is the REST client, pagination, and repo-file fetch; `src/discovery/` resolves multi-repo targets; `src/report/` composes the private report. Under `src/sections/`, each settings section is one self-contained DIRECTORY `src/sections/<key>/`: `index.ts` (the `SectionModule` - key, PAT grant advice, the loose shape derived from the slice, handler), `schema.ts` (the zod slice), `mock.ts` (the e2e mock fragment), `generators.ts` (the fuzz fragment; the secret and variable factory families share theirs in `test/e2e/generators.ts` instead), `<key>.test.ts` (absent where a shared factory's suite under `test/sections/` covers the section), and `scenarios/`. Beside the section directories sit `contract/` (the layered section contract: permissions/endpoints/graphql/module/errors/requests/live), `shared/` (the cross-section engines and factories, plus the schema helpers), and `registry.ts` (the single registration point). All GitHub API list calls must go through `listAll()` (bare-array endpoints) or `listAllEnveloped()` (endpoints that wrap the list in a `{total_count, <key>: []}` envelope), both backed by the single page loop in `src/github/paginate.ts`; errors through `call()`/`throwFor()` so the permission policy (`on-missing-permission`, `required-sections`) works.
3940
- Adding a section: create `src/sections/<key>/` with the six pieces above plus its `<key>.docs.yml` (the Sections table cells for docs/reference/sections.md, the COVERAGE.md prose, and the schema descriptions the generators render; the docs loader fails without it), compose the `SettingsFile` property from the slice and add the key to `SECTION_KEYS` in `src/schema.ts`, and add one line in `src/sections/registry.ts` - the compiler flags each forgotten step (the registry's mapped type, the mock fragment registration, the slice composition pin, and the generator aggregation are all keyed by SectionKey).
4041
- Adding a section endpoint: declare it in the section module's `ENDPOINTS`, add the matching handler under its `section.role` key in the section's `mock.ts` (the fragment's handler record is typed over the section's exact key union, so a missing or phantom handler fails to compile, and `assertHandlerCompleteness()` backstops at construction), add a scenario that reaches it (the coverage tripwire fails on a cold route), and regenerate the trimmed spec (`bun .github/scripts/trim-openapi.ts`; the artifact stays gitignored). `USED_PATHS` (in `test/e2e/openapi/paths.ts`) picks the new path up automatically. Core routes the action calls outside the sections (the repo fetch, the settings-file contents read, `repos: "*"` discovery, and the private-report issue channel) are served by a separate core-path handler, and a request that matches no registered section or core route fails loudly.

0 commit comments

Comments
 (0)