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
6 changes: 5 additions & 1 deletion .github/scripts/changed-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export const ALL_SELECTING_PREFIXES = [
"src/flows/",
"src/github/",
"src/action/",
// The CLI runs the same flows the action does, so its change is smoked like the action's.
"src/cli/",
"src/cli.ts",
"src/discovery/",
"src/report/",
// Cross-cutting: gap files define supplemental route typing across sections.
Expand Down Expand Up @@ -137,7 +140,8 @@ function assertNoComputedImports(text: string, file: string): void {
* errors name the file, not this tool, because arch-lint.ts shares this and resolveImport. */
export function scanImports(text: string, file: string): string[] {
assertNoComputedImports(text, file);
return TRANSPILER.scanImports(text)
// Bun.Transpiler rejects a shebang line (the bin entry keeps one); oxc above accepts it.
return TRANSPILER.scanImports(text.replace(/^#!.*\n/, ""))
.map((entry) => entry.path)
.filter((specifier) => specifier.startsWith("./") || specifier.startsWith("../"));
}
Expand Down
35 changes: 33 additions & 2 deletions .github/scripts/package-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
* subpath), and compile a TypeScript consumer against the bundled index.d.ts
* with skipLibCheck off - a declaration that leaks a devDependency type, or a
* type the emitter could not name, fails here instead of on a consumer's
* machine.
* machine. The installed bin is run under Node too: its help must name every
* subcommand, and `validate` must accept a small settings file.
*
* Usage: `bun .github/scripts/package-smoke.ts` from anywhere; the temp
* directory is removed on every path, failure included.
*/

import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CLI_COMMANDS } from "../../src/cli/program.js";
import { SECTION_KEYS } from "../../src/schema.js";

/** This script lives at .github/scripts/, two levels below the repository root. */
Expand Down Expand Up @@ -56,12 +58,29 @@ const result = validateSettings({ labels: [] });
export const ok: boolean = result.isOk() && first === "repository";
`;

/** The settings file the installed CLI validates: one section, valid as written. */
const SETTINGS_FILE = "labels:\n - name: bug\n color: d73a4a\n";

/** The control: a label without its name, which the validator refuses. */
const INVALID_SETTINGS_FILE = "labels:\n - color: d73a4a\n";

/** Run a command to completion in `cwd`, streaming its output; a non-zero exit throws. */
function run(command: string, args: string[], cwd: string): void {
console.log(`$ ${[command, ...args].join(" ")}`);
execFileSync(command, args, { cwd, stdio: "inherit" });
}

/** Run a command expected to fail; any other exit code, zero included, throws. */
function expectExit(code: number, command: string, args: string[], cwd: string): void {
console.log(`$ ${[command, ...args].join(" ")} # expecting exit ${code}`);
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
if (result.status !== code) {
throw new Error(
`${[command, ...args].join(" ")} exited ${result.status ?? "by signal"}, expected ${code}`,
);
}
}

/** Run a command and return its stdout. */
function capture(command: string, args: string[], cwd: string): string {
console.log(`$ ${[command, ...args].join(" ")}`);
Expand Down Expand Up @@ -125,6 +144,18 @@ async function main(): Promise<void> {
writeFileSync(join(consumer, "main.mjs"), NODE_CONSUMER);
run(CONSUMER_NODE, ["--version"], consumer);
run(CONSUMER_NODE, ["main.mjs"], consumer);
// The bin through its installed shim, under the consumer's Node: the package's own runtime.
const bin = join(consumer, "node_modules", ".bin", "gsac");
const help = capture(CONSUMER_NODE, [bin, "--help"], consumer);
const missing = CLI_COMMANDS.filter((command) => !help.includes(` ${command}`));
if (missing.length > 0) {
throw new Error(`the installed CLI's help names no ${missing.join(", ")} command`);
}
writeFileSync(join(consumer, "settings.yml"), SETTINGS_FILE);
run(CONSUMER_NODE, [bin, "validate", "settings.yml"], consumer);
// The failing control: a bin that swallowed its exit code would pass the line above.
writeFileSync(join(consumer, "invalid.yml"), INVALID_SETTINGS_FILE);
expectExit(1, CONSUMER_NODE, [bin, "validate", "invalid.yml"], consumer);
writeFileSync(join(consumer, "main.ts"), TS_CONSUMER);
run(
join(REPO_ROOT, "node_modules", ".bin", "tsc"),
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +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 npm library (`@vivswan/github-settings-as-code`, built into `lib/pkg/` by `bun run build:lib`, never committed) is described in docs/reference/library.md. One rule the tooling cannot infer: a type a section module exposes must be exported from its home module, or the bundled declarations cannot reach it and the package-smoke job fails.
- `src/cli/` is the command line, a consumer of the public entry like the action: a capability it needs is exported from `src/index.ts` first, never imported across. Guide: docs/start/cli.md.
- 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/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).
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ The [getting started guide](docs/start/getting-started.md) walks the same steps

The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node 22.14 or newer): validate, merge, check, and apply from your own code. It is built from this repository with `bun run build:lib`; the [library reference](docs/reference/library.md) has the API by group.

The package is also a command: `npx @vivswan/github-settings-as-code@next check --repository o/r --settings-file .github/settings.yml` runs the action's check from a terminal; the [command line guide](docs/start/cli.md) has every command.

## Docs

| Goal | Read |
Expand All @@ -85,6 +87,7 @@ The same engine is the npm package `@vivswan/github-settings-as-code` (ESM, Node
| Move a pin to a new major | [Upgrading](docs/upgrading/README.md) |
| See how the code is laid out | [Architecture](docs/reference/architecture.md) |
| Use the engine from your own code | [Library](docs/reference/library.md) |
| Run check, apply, or validate from a terminal | [Command line](docs/start/cli.md) |

## Contributing

Expand Down
4 changes: 3 additions & 1 deletion architecture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
layers:
main: [src/main.ts]
action: [src/action/]
cli: [src/cli.ts, src/cli/]
library: [src/index.ts]
flows: [src/flows/]
engine: [src/engine/]
Expand All @@ -28,10 +29,11 @@ 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.
# The action and the CLI are consumers of the library: their one edge is src/index.ts.
edges:
main: [action]
action: [library]
cli: [library]
library: [discovery, engine, flows, github, io, problem, report, schema, sections, types]
flows: [discovery, engine, github, io, plain-data, private, problem, report, schema, types]
engine: [discovery, github, io, plain-data, problem, schema, sections, types]
Expand Down
9 changes: 8 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the
| Predict what an apply or a check will do | [reference/semantics.md](reference/semantics.md) |
| See how the action works, module by module | [reference/architecture.md](reference/architecture.md) |
| Use the engine from your own code | [reference/library.md](reference/library.md) |
| Run check, apply, or validate from a terminal | [start/cli.md](start/cli.md) |
| Detect drift on a schedule | [operate/check-mode.md](operate/check-mode.md) |
| Fold several settings files into one | [operate/layering.md](operate/layering.md) |
| Manage a fleet from one repository | [operate/multi-repo.md](operate/multi-repo.md) |
Expand All @@ -32,6 +33,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the
- [Getting started](start/getting-started.md): create the PAT, add the workflow, run your first check, and read the drift output.
- [Migrating from the Probot Settings app](start/migrating-from-probot.md): the step-by-step move, including the parts that changed on purpose and an org-scale shadow run.
- [Examples](start/examples.md): a settings.yml cookbook, from a minimal file to a full-featured one, including what `null` means where it is meaningful.
- [Command line](start/cli.md): the `github-settings-as-code` and `gsac` commands in the npm package, one per action mode plus `validate` and `permissions`, their flags, outputs, and exit codes.

## reference: the normative model

Expand Down
2 changes: 2 additions & 0 deletions docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ 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/"]
cli["src/cli.ts<br>src/cli/"]
library["src/index.ts"]
flows["src/flows/"]
engine["src/engine/"]
Expand All @@ -246,6 +247,7 @@ graph TD
upstream_gaps["src/upstream-gaps/"]
main --> action
action --> library
cli --> library
library --> discovery
library --> engine
library --> flows
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ const exitCode = await runSingle(client, config, collected.io).match(
console.log(exitCode, collected.outputs.result, collected.lines.map((entry) => entry.line));
```

## CLI

The package's `bin` entries, `github-settings-as-code` and `gsac`, run the same flows from a terminal: `check`, `apply`, and `merge` take the action's inputs as `--flags`,
and `validate` and `permissions` read a settings file alone. The [command line guide](../start/cli.md) has every command, the flag rule, and the exit codes.

## Versioning

The package and the action share one version, the one in `.release-please-manifest.json` (release-please rewrites `package.json` from it), so a settings file that validates on the library validates on the action of the same version.
Expand Down
Loading
Loading