Skip to content

Commit 6bc1674

Browse files
author
Vivswan Shah
committed
refactor(arch): move the run flows to src/flows and add the public entry src/index.ts
The single-, multi-, and merge-run flows, delivery, redaction, settings reading, and the step summaries move from src/action/ to src/flows/; the secret-reference module moves to src/engine/, the private-report channel list and the redacted-detail types to src/report/delivery.ts, and the @actions/artifact uploader to src/action/artifact.ts behind the report layer's ArtifactUploader port. src/index.ts is the public entry, and src/action/ reaches the rest of src/ only through it: architecture.yml grants the action that one edge, so the action is a consumer of the library. No behavior changes: every flow, message, output, and test keeps its meaning, and the single-repo body of run() becomes runSingle, symmetric with runMulti.
1 parent b8df084 commit 6bc1674

60 files changed

Lines changed: 1316 additions & 515 deletions

Some content is hidden

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ export const ALL_SELECTING_PREFIXES = [
6464
// so a change there selects everything.
6565
"src/sections/contract/",
6666
"src/engine/",
67+
"src/flows/",
6768
"src/github/",
6869
"src/action/",
6970
"src/discovery/",
7071
"src/report/",
7172
// Cross-cutting: gap files define supplemental route typing across sections.
7273
"src/upstream-gaps/",
74+
"src/index.ts",
7375
"src/io.ts",
7476
"src/main.ts",
7577
"src/plain-data.ts",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
// 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";
98
import { REPO_RESULTS, type RepoResult } from "../../src/engine/orchestrate.js";
9+
import { MERGE_RESULT } from "../../src/flows/deliver.js";
1010
import type { SectionDocs } from "../../src/sections/contract/docs.js";
1111
import {
1212
type SectionMeta,

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ GitHub Settings as Code: GitHub Action applying declarative repository settings:
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).
3838
- 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.
39-
- 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.
39+
- 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.
4040
- 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).
4141
- 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.
4242
- 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.

‎architecture.yml‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
layers:
1010
main: [src/main.ts]
1111
action: [src/action/]
12+
library: [src/index.ts]
13+
flows: [src/flows/]
1214
engine: [src/engine/]
1315
sections: [src/sections/]
1416
github: [src/github/]
@@ -28,13 +30,16 @@ exclude:
2830
- "src/**/*.d.ts"
2931

3032
# from -> the layers it may import; a layer absent here imports nothing outside itself.
33+
# The action is a consumer of the library: its one edge is src/index.ts.
3134
edges:
3235
main: [action]
33-
action: [discovery, engine, github, io, private, report, schema, types]
34-
engine: [action, discovery, github, io, plain-data, schema, sections, types]
36+
action: [library]
37+
library: [discovery, engine, flows, github, io, report, schema, sections, types]
38+
flows: [discovery, engine, github, io, private, report, schema]
39+
engine: [discovery, github, io, plain-data, schema, sections, types]
3540
sections: [discovery, engine, github, schema, types, upstream-gaps]
3641
github: [io, plain-data]
3742
discovery: [github, private]
38-
report: [action, discovery, engine, github, io, private, schema, sections, types]
43+
report: [discovery, engine, github, io, private, schema, sections, types]
3944
schema: [sections, types]
4045
upstream-gaps: [types]

‎biome.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
}
7474
},
7575
{
76-
"includes": ["src/action/redact.ts"],
76+
"includes": ["src/flows/redact.ts"],
7777
"linter": {
7878
"rules": {
7979
"style": {

‎docs/reference/architecture.md‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The [module map](#the-module-map) at the end is generated from [architecture.yml
1414

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

141141
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.
142142

143-
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).
143+
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).
144144

145145
## The section-module contract
146146

@@ -191,7 +191,7 @@ Demonstrated by: [test/sections/registry.test.ts](https://github.com/Vivswan/git
191191
```mermaid
192192
flowchart TD
193193
targets["src/discovery/central.ts resolveCentralTargets()<br>src/discovery/discover.ts discoverRepos()"]
194-
each["one target at a time<br>src/action/multi.ts runMulti()"]
194+
each["one target at a time<br>src/flows/multi.ts runMulti()"]
195195
own["applied from that file, as written"]
196196
fetch["src/github/repo-file.ts<br>getRepoFile()"]
197197
probe["the repository object names the default branch"]
@@ -219,7 +219,7 @@ Targets come from checked-in files under `repos-dir`, from the `repos` list, or
219219
- 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.
220220
- 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.
221221

222-
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).
222+
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).
223223

224224
## The module map
225225

@@ -230,6 +230,8 @@ Each node is one layer of `src/`, labelled with the paths it owns; an arrow mean
230230
graph TD
231231
main["src/main.ts"]
232232
action["src/action/"]
233+
library["src/index.ts"]
234+
flows["src/flows/"]
233235
engine["src/engine/"]
234236
sections["src/sections/"]
235237
github["src/github/"]
@@ -242,15 +244,23 @@ graph TD
242244
private["src/private.ts<br>src/private-open.ts"]
243245
upstream_gaps["src/upstream-gaps/"]
244246
main --> action
245-
action --> discovery
246-
action --> engine
247-
action --> github
248-
action --> io
249-
action --> private
250-
action --> report
251-
action --> schema
252-
action --> types
253-
engine --> action
247+
action --> library
248+
library --> discovery
249+
library --> engine
250+
library --> flows
251+
library --> github
252+
library --> io
253+
library --> report
254+
library --> schema
255+
library --> sections
256+
library --> types
257+
flows --> discovery
258+
flows --> engine
259+
flows --> github
260+
flows --> io
261+
flows --> private
262+
flows --> report
263+
flows --> schema
254264
engine --> discovery
255265
engine --> github
256266
engine --> io
@@ -268,7 +278,6 @@ graph TD
268278
github --> plain_data
269279
discovery --> github
270280
discovery --> private
271-
report --> action
272281
report --> discovery
273282
report --> engine
274283
report --> github

‎knip.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"$schema": "https://unpkg.com/knip@6/schema.json",
33
"entry": [
44
"src/main.ts",
5+
"src/index.ts",
56
"src/schema.ts",
67
".github/scripts/*.ts",
78
"**/*.test.ts",

‎src/action/artifact.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* The ArtifactUploader port over @actions/artifact: the one production
3+
* implementation, so the report layer never touches the artifact service.
4+
*/
5+
6+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
import { DefaultArtifactClient } from "@actions/artifact";
10+
import type { ArtifactUploader } from "../index.js";
11+
12+
/** @actions/artifact uploads files from disk, so the ciphertext round-trips through a private temp directory. */
13+
export const actionsArtifactUploader: ArtifactUploader = {
14+
async upload(name, file) {
15+
// DefaultArtifactClient emits its own core.warning before it throws on a
16+
// missing runtime token, so invoking it without one would double-warn.
17+
// Fail before constructing it; deliverArtifactReport turns this into the
18+
// single warning callers see.
19+
if (!process.env.ACTIONS_RUNTIME_TOKEN) {
20+
throw new Error(
21+
"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)",
22+
);
23+
}
24+
const dir = await mkdtemp(join(tmpdir(), "settings-as-code-report-"));
25+
try {
26+
const path = join(dir, file.name);
27+
await writeFile(path, file.data);
28+
await new DefaultArtifactClient().uploadArtifact(name, [path], dir);
29+
} finally {
30+
await rm(dir, { recursive: true, force: true });
31+
}
32+
},
33+
};

0 commit comments

Comments
 (0)