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: 2 additions & 0 deletions .github/scripts/changed-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ export const ALL_SELECTING_PREFIXES = [
// so a change there selects everything.
"src/sections/contract/",
"src/engine/",
"src/flows/",
"src/github/",
"src/action/",
"src/discovery/",
"src/report/",
// Cross-cutting: gap files define supplemental route typing across sections.
"src/upstream-gaps/",
"src/index.ts",
"src/io.ts",
"src/main.ts",
"src/plain-data.ts",
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/gen-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// map rendered from architecture.yml. Authored prose from the docs registry + coverage-data.
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { MERGE_RESULT } from "../../src/action/deliver.js";
import { REPO_RESULTS, type RepoResult } from "../../src/engine/orchestrate.js";
import { MERGE_RESULT } from "../../src/flows/deliver.js";
import type { SectionDocs } from "../../src/sections/contract/docs.js";
import {
type SectionMeta,
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings:

- `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).
- 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.
- 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.
- The apply/check engine layout: `src/main.ts` is the thin bundled entrypoint; `src/index.ts` is the public entry (declaration-only re-exports, pinned by test/library/public-surface.test.ts); `src/action/` is the GitHub Actions layer and a CONSUMER of that entry: its only import outside itself is `../index.js`, the one edge `architecture.yml` grants it; `src/flows/` holds the run flows the action and a library caller share (single, multi, merge, delivery, redaction); `src/io.ts` is the import-free Io output port; `src/types.ts` holds the leaf type vocabulary; `src/engine/` is the per-repo pipeline (orchestrate, validate, merge, diff, secret references); `src/github/` is the REST client, pagination, and repo-file fetch; `src/discovery/` resolves multi-repo targets; `src/report/` composes and delivers 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.
- 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).
- 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.
- Two declarations on each section are single sources the rest of the system reads: `permission` (a `SectionPermission`) drives the PAT grant prose (`grantFor`), the mock's permission gate, and the fuzz oracle; the `ENDPOINTS` dictionary drives the paths, the mock routes, and `USED_PATHS`. `undeclaredDefault` drives the Undeclared-default column of the generated Sections table on docs/reference/sections.md (`bun run build:docs`; `build:check` fails on drift). Change one and its consumers follow.
Expand Down
11 changes: 8 additions & 3 deletions architecture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
layers:
main: [src/main.ts]
action: [src/action/]
library: [src/index.ts]
flows: [src/flows/]
engine: [src/engine/]
sections: [src/sections/]
github: [src/github/]
Expand All @@ -28,13 +30,16 @@ exclude:
- "src/**/*.d.ts"

# from -> the layers it may import; a layer absent here imports nothing outside itself.
# The action is a consumer of the library: its one edge is src/index.ts.
edges:
main: [action]
action: [discovery, engine, github, io, private, report, schema, types]
engine: [action, discovery, github, io, plain-data, schema, sections, types]
action: [library]
library: [discovery, engine, flows, github, io, report, schema, sections, types]
flows: [discovery, engine, github, io, private, report, schema]
engine: [discovery, github, io, plain-data, schema, sections, types]
sections: [discovery, engine, github, schema, types, upstream-gaps]
github: [io, plain-data]
discovery: [github, private]
report: [action, discovery, engine, github, io, private, schema, sections, types]
report: [discovery, engine, github, io, private, schema, sections, types]
schema: [sections, types]
upstream-gaps: [types]
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
}
},
{
"includes": ["src/action/redact.ts"],
"includes": ["src/flows/redact.ts"],
"linter": {
"rules": {
"style": {
Expand Down
43 changes: 26 additions & 17 deletions docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The [module map](#the-module-map) at the end is generated from [architecture.yml

```mermaid
flowchart TD
read["src/action/settings-read.ts<br>readSettingsFile()"]
read["src/flows/settings-read.ts<br>readSettingsFile()"]
mode{"mode"}
fold["src/engine/layers.ts<br>stripNulls() mergeLayers()"]
validate["src/engine/orchestrate.ts<br>validateSettingsDoc()"]
Expand All @@ -25,7 +25,7 @@ flowchart TD
api["src/github/api.ts<br>GithubClient"]
drift["check: the plan's drift lines<br>src/sections/contract/plan.ts planDrift() planCheckNotes()"]
exec["apply: the plan's writes<br>src/engine/execute.ts executePlan()"]
report["src/action/deliver.ts<br>concludeRun()"]
report["src/flows/deliver.ts<br>concludeRun()"]
read -->|YAML text, parsed to an unknown document per file| mode
mode -->|merge: every layer, each validated on its own first| fold
fold -->|one folded document, a notice per null opt-out| validate
Expand Down Expand Up @@ -124,8 +124,8 @@ Demonstrated by: [test/e2e/scenarios/merge-union-optout.yml](https://github.com/
flowchart LR
operator["operator-authored documents<br>settings-file layers, repos-dir files, the defaults-file"]
target["target-authored documents<br>a repository's own settings file, fetched via repos"]
refs["src/action/secret-refs.ts<br>validateSecretRef()"]
resolve["src/action/secret-refs.ts<br>resolveSecretRefs()"]
refs["src/engine/secret-refs.ts<br>validateSecretRef()"]
resolve["src/engine/secret-refs.ts<br>resolveSecretRefs()"]
refused["hard error for that section"]
operator -->|SettingsSource operator| refs
target -->|SettingsSource target| refs
Expand All @@ -140,7 +140,7 @@ Two kinds of document reach the engine:

A `$NAME` secret reference resolves from the workflow step's environment, so it is honored only in operator documents. A target repository must never be able to route the operator's secrets into itself. Provenance is a property of the source document, decided once where the document is chosen, and every value in it shares that one source.

Demonstrated by: [test/action/secret-refs.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/action/secret-refs.test.ts), [test/engine/secrets.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/secrets.test.ts), [test/e2e/scenarios/multi-secrets-target-ref-rejected.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-secrets-target-ref-rejected.yml).
Demonstrated by: [test/engine/secret-refs.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/secret-refs.test.ts), [test/engine/secrets.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/engine/secrets.test.ts), [test/e2e/scenarios/multi-secrets-target-ref-rejected.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-secrets-target-ref-rejected.yml).

## The section-module contract

Expand Down Expand Up @@ -191,7 +191,7 @@ Demonstrated by: [test/sections/registry.test.ts](https://github.com/Vivswan/git
```mermaid
flowchart TD
targets["src/discovery/central.ts resolveCentralTargets()<br>src/discovery/discover.ts discoverRepos()"]
each["one target at a time<br>src/action/multi.ts runMulti()"]
each["one target at a time<br>src/flows/multi.ts runMulti()"]
own["applied from that file, as written"]
fetch["src/github/repo-file.ts<br>getRepoFile()"]
probe["the repository object names the default branch"]
Expand Down Expand Up @@ -219,7 +219,7 @@ Targets come from checked-in files under `repos-dir`, from the `repos` list, or
- A contents 404 alone proves nothing (a missing file, a missing grant, or an invisible repository all look the same). The proof is the default-branch ref read, a call that needs Contents: read and succeeds whether or not the file exists: 200 means the token could have read the file, so the file is absent.
- A ref read that is denied leaves the proof inconclusive, and the target fails naming the grant. A denial must never look like a missing file.

Demonstrated by: [test/action/multi.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/action/multi.test.ts), [test/e2e/scenarios/multi-missing-and-failing.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-missing-and-failing.yml), [test/e2e/scenarios/multi-contents-denied.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-contents-denied.yml).
Demonstrated by: [test/flows/multi.test.ts](https://github.com/Vivswan/github-settings-as-code/blob/main/test/flows/multi.test.ts), [test/e2e/scenarios/multi-missing-and-failing.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-missing-and-failing.yml), [test/e2e/scenarios/multi-contents-denied.yml](https://github.com/Vivswan/github-settings-as-code/blob/main/test/e2e/scenarios/multi-contents-denied.yml).

## The module map

Expand All @@ -230,6 +230,8 @@ Each node is one layer of `src/`, labelled with the paths it owns; an arrow mean
graph TD
main["src/main.ts"]
action["src/action/"]
library["src/index.ts"]
flows["src/flows/"]
engine["src/engine/"]
sections["src/sections/"]
github["src/github/"]
Expand All @@ -242,15 +244,23 @@ graph TD
private["src/private.ts<br>src/private-open.ts"]
upstream_gaps["src/upstream-gaps/"]
main --> action
action --> discovery
action --> engine
action --> github
action --> io
action --> private
action --> report
action --> schema
action --> types
engine --> action
action --> library
library --> discovery
library --> engine
library --> flows
library --> github
library --> io
library --> report
library --> schema
library --> sections
library --> types
flows --> discovery
flows --> engine
flows --> github
flows --> io
flows --> private
flows --> report
flows --> schema
engine --> discovery
engine --> github
engine --> io
Expand All @@ -268,7 +278,6 @@ graph TD
github --> plain_data
discovery --> github
discovery --> private
report --> action
report --> discovery
report --> engine
report --> github
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"src/main.ts",
"src/index.ts",
"src/schema.ts",
".github/scripts/*.ts",
"**/*.test.ts",
Expand Down
33 changes: 33 additions & 0 deletions src/action/artifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* The ArtifactUploader port over @actions/artifact: the one production
* implementation, so the report layer never touches the artifact service.
*/

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DefaultArtifactClient } from "@actions/artifact";
import type { ArtifactUploader } from "../index.js";

/** @actions/artifact uploads files from disk, so the ciphertext round-trips through a private temp directory. */
export const actionsArtifactUploader: ArtifactUploader = {
async upload(name, file) {
// DefaultArtifactClient emits its own core.warning before it throws on a
// missing runtime token, so invoking it without one would double-warn.
// Fail before constructing it; deliverArtifactReport turns this into the
// single warning callers see.
if (!process.env.ACTIONS_RUNTIME_TOKEN) {
throw new Error(
"the artifact service is unavailable: no ACTIONS_RUNTIME_TOKEN in the environment. Artifact upload needs a GitHub-hosted or self-hosted Actions runner (it is not available on GitHub Enterprise Server or outside Actions)",
);
}
const dir = await mkdtemp(join(tmpdir(), "settings-as-code-report-"));
try {
const path = join(dir, file.name);
await writeFile(path, file.data);
await new DefaultArtifactClient().uploadArtifact(name, [path], dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
},
};
Loading
Loading