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
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all
- `bun run test` and `bun run fuzz` run `bun run build:schema` first: the tests and the fuzzer load the built, gitignored `lib/settings.schema.json`.
- Committed generated output is the table in `.github/scripts/generated.ts`: the whole files under `src/generated/` and the generated regions of `action.yml` and the docs pages.
- `bun run build:check` regenerates every table entry and fails on drift.
- `lib/index.js` (the action bundle), `lib/settings.schema.json` (the published schema), and `lib/pkg/` (the npm library) are built where they are needed and never committed on `main`. Every runtime dependency is compiled into the bundle and the library.
- `lib/index.js` (the action bundle), `lib/settings.schema.json` (the published schema), and `lib/pkg/` (the npm library) are built where they are needed and never committed on `main`.
- The action bundle compiles every runtime dependency in. The library keeps them external: they are the `dependencies` of `package.json`, installed by the consumer's package manager.
- [docs/reference/coverage.md](docs/reference/coverage.md) is the inventory of the supported API surface, one link per call. A change that adds or extends a section keeps its `<key>.docs.yml` rows and `.github/scripts/endpoint-docs.yml` in step.

## Backward compatibility

- A compat path that stays (an alias, a retired input still accepted, an arm for an older artifact) carries a comment `COMPAT(vN): <what stays working and what to delete>`, N the major that deletes it. Compat kept today for a pre-3 shape is marked v3; compat introduced during 3.x for a 3.0 shape is marked v4.
- A compat path that stays (an alias, a retired input still accepted, an arm for an older artifact) carries a comment `COMPAT(vN): <what stays working and what to delete>`, N the major that deletes it.
- N is above the current major: compat kept for a shape of the current major is marked with the next one.
- JSON takes no comments, so compat in a JSON file is marked in the code that reads it.
- A path that must work forever is not compat and gets no marker.
- `bun run check:compat` (in `bun run check` and in CI) rejects a malformed marker and any marker whose major is at or below `package.json`'s, and prints the remaining markers grouped by major.
- A release PR's tree already carries the version it cuts, so the same check makes a major release PR unmergeable until every marker for that major is deleted on `main` first: with 3.0.0 in preparation, every v3-marked path goes before v3 cuts.
- A release PR's tree already carries the version it cuts, so the same check makes a major release PR unmergeable until every marker for that major is deleted on `main` first.

## Code conventions

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state

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.

- Until the first stable release publishes the package, ask for the `next` tag: `npm install @vivswan/github-settings-as-code@next` installs the pre-release of the `main` commit release-please last refreshed the release PR on ([Versioning](docs/reference/library.md#versioning)). The [library reference](docs/reference/library.md) has the API by group and the versioning rules.
- `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.
- `npm install @vivswan/github-settings-as-code` installs the released version. The [library reference](docs/reference/library.md) has the API by group and the versioning rules.
- `npm install @vivswan/github-settings-as-code@next` installs the pre-release of `main`: the commit release-please last refreshed the release PR on ([Versioning](docs/reference/library.md#versioning)).
- `npx @vivswan/github-settings-as-code 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

Expand Down
8 changes: 6 additions & 2 deletions docs/operate/check-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ order: 210

# Check mode

`mode: check` runs the whole pipeline read-only. Each declared section reads the live state and reports how it differs from the settings file instead of writing anything. The same declared-keys-only rule as apply holds: a key you do not declare is never compared (see [Semantics](../reference/semantics.md)).
`mode: check` runs the whole pipeline read-only. Each declared section reads the live state and reports how it differs from the settings file instead of writing anything.

The same declared-keys-only rule as apply holds: a key you do not declare is never compared. The exception is a declared ruleset, environment, or protected branch, whose write replaces the whole object: check sweeps the live object for values the entry omits and that write would remove, and reports each one it finds ([Semantics](../reference/semantics.md) says where the sweep stops).

Check mode changes no settings. The one write it can still perform is delivery of a private report when the `private-report` input is enabled, and that delivery touches only its report issue, its marker label, or its encrypted artifact, nothing else; the [private repositories guide](private-repositories.md#delivering-a-private-report) owns what each channel writes and when.

Expand Down Expand Up @@ -77,7 +79,9 @@ Read this run as a preview, not a gate. A pull request that changes settings is
Two phrases in that sentence need unpacking:

- **Live state, not the base branch.** The comparison is the proposed file against the live repository, not against the base branch, so drift that existed before the PR shows up too. That is still the true apply-on-merge delta, but not every line is caused by the PR.
- **Something check can verify.** Write-only values (the LFS toggle, the interaction-limit expiry) are re-asserted by every apply without ever drifting a check. A ruleset entry that declares fewer fields than the live ruleset carries can narrow it on apply without showing drift, because only declared fields are compared.
- **Something check can verify.** Write-only values (the LFS toggle, the interaction-limit expiry) are re-asserted by every apply without ever drifting a check.

A ruleset has a blind spot inside a rule, its `parameters` included: a key the entry omits there is not compared. A live `allowed_merge_methods` beside a declared `pull_request` rule without it reads clean, and the next update of that ruleset sends the rule without it.

A broken file still fails usefully: YAML parse errors and malformed section entries fail the run before any comparison.

Expand Down
8 changes: 3 additions & 5 deletions docs/operate/snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Byte-identical output holds for the lists left as written while GitHub returns e

## The `$NAME` placeholders

GitHub never returns a secret's value, so the file cannot hold one. The snapshot writes a reference instead, and the note tells you which variable to export before an apply or check runs the file:
GitHub never returns a secret's value, so the file cannot hold one. The snapshot writes a reference instead, and the note tells you which variable to export before an apply runs the file:

| What GitHub hides | What the file holds | What to export |
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand All @@ -86,7 +86,7 @@ GitHub never returns a secret's value, so the file cannot hold one. The snapshot
| A webhook's `config.secret`, one per hook | `secret: $SECRET_WEBHOOK_601` | `SECRET_WEBHOOK_<id>`, the hook's own id, so the reference survives a reordering |
| An environment secret named `DEPLOY_TOKEN` in the `production` environment | `value: $SECRET_ENVIRONMENT_PRODUCTION_DEPLOY_TOKEN` | `SECRET_ENVIRONMENT_<ENV>_<NAME>`, the environment name uppercased with every other character folded to `_`, so same-named secrets in two environments get two variables; two names the fold collapses (`prod-eu`, `prod_eu`) share one, and a header line names the entries to edit |

Wire them the way the [secrets guide](../reference/secrets-and-vaults.md) describes: an `env:` block on the apply step, fed from GitHub Secrets or a vault action. A reference that is not exported fails the run that reads the file, naming the variable.
Wire them the way the [secrets guide](../reference/secrets-and-vaults.md) describes: an `env:` block on the apply step, fed from GitHub Secrets or a vault action. A reference that is not exported fails an apply that runs its section, naming the variable. A check resolves no reference, so it needs none of them.

## Backup, then check

Expand All @@ -110,8 +110,6 @@ jobs:
mode: snapshot
snapshot-file: backup/settings.yml
- uses: Vivswan/github-settings-as-code@v3 # x-release-please-major
env:
SECRET_ACTIONS_DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
with:
token: ${{ secrets.ADMIN_TOKEN }}
mode: check
Expand Down Expand Up @@ -150,7 +148,7 @@ A snapshot is written so that applying it changes nothing and checking it reads
| A branch only a wildcard rule protects | GitHub serves the rule's protection under the branch's name; a literal entry would create a second rule on apply | The wildcard entry alone (`release/*`), never the branch |
| `collaborators`: the repository owner, email invitations | The owner's access is implicit, and an email invitation has no username to declare | No entry and a note each; apply leaves both alone |
| `collaborators`: expired invitations | A declared one would be cancelled and re-sent; an undeclared one is cancelled under the delete default the file carries | No entry and a note per invitation; add the entry to re-invite |
| A custom role named `push` or `pull` | In a settings file those words mean the `write` and `read` roles, so no declaration plans as the live role | `collaborators` fails and the file is written without it; `teams` omits the team with a note |
| A custom role named `push` or `pull` | In a settings file those words mean the `write` and `read` roles, so no declaration plans as the live role | `collaborators` fails, so the target fails and no file is written; `teams` omits the team with a note |
| `teams`: access granted at the organization or enterprise level | Declaring it would grant direct repository access on top | No entry and a note; apply leaves it alone |

A section that exists but holds nothing live (no milestones, no Pages site) is omitted with a header line, never written as an empty list: an empty list under `_undeclared: delete` would delete on apply.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Two limits. A shape's own issues are capped at five per section, with a count of

The success path mints the input a planner accepts: a section's `plan()` takes the section's value carrying a type-level brand that names the section it was validated as. A hand-built entry list does not compile. A nullable section's `null` carries no brand: it holds nothing a file-only check could judge.

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)).
A secret reference is judged twice. Its `$NAME` form and its provenance are issues of this phase, which is told who authored the document ([Trust and provenance](#trust-and-provenance)) and reads no environment. Its value waits for apply alone, which resolves the references of the sections the run applies before the first write of any section, and after the read-only preflight when the run has one.

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).

Expand Down
23 changes: 17 additions & 6 deletions docs/reference/forward-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GitHub ships new settings fields faster than any wrapper can track them, so this

This page draws the line: where the passthrough tenet holds, and where a shape is deliberately closed.

The passthrough is the default: every section not named in the closed list below accepts fields it does not recognize and sends them through. The richest passthrough sections are `rulesets` (new rule types, bypass-actor fields, condition types), `repository`, `branches`, `environments`, `actions`, `pages`, and `code_scanning_default_setup`.
The passthrough is the default: every section not named in the closed list below accepts fields it does not recognize and sends them through. The [closed nested shapes](#the-closed-nested-shapes) inside some of those sections are the exception. The richest passthrough sections are `rulesets` (new rule types, bypass-actor fields, condition types), `repository`, `branches`, `environments`, `actions`, `pages`, and `code_scanning_default_setup`.

The published JSON Schema follows the same tenet. It is documentation, not a gate, so unknown fields validate on purpose: declaring a field GitHub ships tomorrow must never read as an error.

Expand All @@ -19,16 +19,27 @@ Two deliberate boundaries:

## The closed sections

Nine sections are closed rather than passthrough: `collaborators`, `teams`, `workflows`, `custom_properties`, `secret_scanning_custom_patterns`, and the secret sections `actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, and `agents_secrets` reject entry keys they do not recognize.
Ten sections are closed rather than passthrough: `collaborators`, `teams`, `workflows`, `custom_properties`, `secret_scanning_custom_patterns`, the secret sections `actions_secrets`, `dependabot_secrets`, `codespaces_secrets`, and `agents_secrets`, and `interaction_limits` reject keys they do not recognize.

Their API calls carry at most the declared fields per entry (a `permission`, a property `value`, a sealed secret value, or a pattern's own fields; the workflow enable/disable calls carry none), so an extra key can only be a typo. Two typos a passthrough would have accepted silently:
The first nine are lists, and the key they refuse is an entry's. Their API calls carry at most the declared fields per entry (a `permission`, a property `value`, a sealed secret value, or a pattern's own fields; the workflow enable/disable calls carry none), so an extra key can only be a typo. Two typos a passthrough would have accepted silently:

- **A misspelled `permission`** would grant the default `push` role and never show up as drift.
- **A pattern's `state` and `push_protection_enabled`** are read-only through the custom-pattern endpoints and would do nothing.

A few nested shapes are strict for the same reason:
`interaction_limits` is one mapping, and the key it refuses is its own: GitHub reports `origin` and `expires_at` beside the limit and accepts neither. Its `pull_request_creation_cap` stays passthrough.

- **The actions section's `cache` object.** Each key of it is the entire body of its own endpoint, so an unrecognized cache key has nowhere to go and is rejected upfront; the rest of that section stays passthrough.
- **The `secrets` and `deployment_protection_rules` entries inside a declared environment.** Their write bodies are built from the named fields alone, so an extra entry key would silently do nothing.
## The closed nested shapes

These mappings inside passthrough sections refuse a key they do not recognize.

| Shape | Why an extra key is refused |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `actions.cache` | Each key is the entire body of its own endpoint, so another key has nowhere to go |
| `actions.selected_actions` | It is the whole body of the allowlist PUT, and GitHub documents three fields for it |
| `repository.security_and_analysis` | The schema names every sub-key the PATCH accepts; every mapping under it is closed too |
| `branches[].protection.required_status_checks.checks[]` | GitHub's protection PUT takes only `context` and `app_id` in a check item |
| `branches[].protection.required_deployments` | The rule mutation is built from `environments` alone |
| `environments[].secrets[]` | The write body is built from the sealed value alone |
| `environments[].deployment_protection_rules[]` | The enable call sends only the App's resolved id |

The wrapped form of the list sections is strict the same way: its keys are this action's own vocabulary, never sent to GitHub, so any other wrapper key is rejected upfront as a typo. The [undeclared policy page](undeclared-policy.md) is the one place that lists them.
Loading
Loading