diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a3f2469..acacbea7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `.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): `, 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): `, 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 diff --git a/README.md b/README.md index dfee9ae7..26fd03ab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/operate/check-mode.md b/docs/operate/check-mode.md index d8a7be55..54036a8c 100644 --- a/docs/operate/check-mode.md +++ b/docs/operate/check-mode.md @@ -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. @@ -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. diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index b8755db8..572d83ec 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -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 | | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -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_`, 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__`, 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 @@ -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 @@ -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. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index c9674895..20b0d219 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -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). diff --git a/docs/reference/forward-compatibility.md b/docs/reference/forward-compatibility.md index dc367e2e..74ada8f3 100644 --- a/docs/reference/forward-compatibility.md +++ b/docs/reference/forward-compatibility.md @@ -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. @@ -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. diff --git a/docs/reference/library.md b/docs/reference/library.md index 8e5aa226..b4d81267 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -11,8 +11,8 @@ The engine behind the action is also an npm package, `@vivswan/github-settings-a Three ways in, one package: ```bash -npm install @vivswan/github-settings-as-code@next # the pre-release of the main commit the release PR was last refreshed on; ask for it until the first stable release -npm install @vivswan/github-settings-as-code # the released version (npm dist-tag latest), a 0.0.0 placeholder until the first stable release +npm install @vivswan/github-settings-as-code # the released version (npm dist-tag latest) +npm install @vivswan/github-settings-as-code@next # the pre-release of the main commit the release PR was last refreshed on npm install github:Vivswan/github-settings-as-code# # one packaged commit: a build tag's or a release tag's ``` @@ -44,7 +44,8 @@ One table per group: the name, what kind of thing it is, and what it says. Each How a call reports failure depends on its group: - A call that reads or parses input, or runs a whole flow (`validateSettings`, `mergeSettings`, `readSettingsFile`, `parseRepoSlug`, `discoverRepos`, `parseRecipient`, `runSingle`, `runMulti`, `runRender`, ...), returns a [neverthrow](https://github.com/supermacro/neverthrow) `Result` (or `ResultAsync`) whose error is a typed `Problem`; `describeProblem` renders one as the message the action would print. -- The repository verbs, `checkRepository`, `applyRepository`, and `snapshotRepository`, always resolve to a report: its `result` field carries the outcome (`clean`, `drift`, `applied`, `partial`, `skipped`, `failed`, `snapshot`) and its `outcomes` say what each section did. Every result word of every mode, the render's `rendered` included, is a `RunOutcome`; `RUN_RESULTS` ranks them worst first and `worstOf` folds a run's targets through that ranking. +- The repository verbs, `checkRepository`, `applyRepository`, and `snapshotRepository`, always resolve to a report: its `result` field carries the outcome (`clean`, `drift`, `applied`, `partial`, `failed`, `snapshot`) and its `outcomes` say what each section did. +- Every result word of every mode is a `RunOutcome`; `RUN_RESULTS` ranks them worst first and `worstOf` folds a run's targets through that ranking. Two never come from a repository verb: the render's `rendered`, and `skipped`, which `runMulti` gives a `repos` target with no settings file when no `defaults-file` is set. - Report delivery: `deliverArtifactReport` never throws and returns `{ uploaded: true }` or `{ warning }`. Two calls throw instead: `encryptReport` on a recipient `parseRecipient` would have rejected (validate it first), and `openReportChannel` when asked for the `artifact` channel without an `ArtifactUploader`. Every verb takes its inputs positionally and one options object of the same knobs, each defaulted as the action's input of the same name: @@ -114,7 +115,9 @@ console.log(merged.value.yaml, merged.value.notices.length); | `UNDECLARED_POLICY_SECTIONS` | const | The list sections whose wrapper takes `_undeclared` beside `_layering`; `environments`, `branches`, and `workflows` layer by key too, through a `{_layering, entries}` wrapper of their own (`LIST_SECTIONS` in the schema module) | | `UndeclaredPolicySection` | type | One of them | -The schema subpath serves the built JSON Schema. Inside a section, `SettingsFile` parses as the action does: an undeclared key survives the parse, and a key a section rule refuses fails it with the key named. The root strips an unknown top-level key, so a caller who wants the action's whole judgment, unknown sections included, calls `validateSettings`. +The schema subpath serves the built JSON Schema. + +Inside a section, `SettingsFile` applies the section's schema as the action does: a key the action passes through survives the parse, and a key the schema refuses fails it with the key named. It strips an unknown top-level key and runs none of the checks beside the schema (a closed section's entry keys, duplicates, a secret value's form), so the action's whole judgment is `validateSettings`. ```ts import { SECTION_KEYS, SettingsFile } from "@vivswan/github-settings-as-code"; @@ -214,7 +217,7 @@ console.log(snapshot.result, snapshot.yaml ?? "(failed: no document)"); | `ConfigEnv` | type | The environment `parseConfig` reads | | `runSingle` | function | One repository from a local settings file, in check or apply | | `SingleConfig` | type | Its config | -| `SingleOutcome` | type | Its result: the target's `result`, `outcomes`, and detail | +| `SingleOutcome` | type | Its result: the target's `result`, its public `display` label, and its `detail` | | `runMulti` | function | The fleet: repos-dir files, the `repos` list, discovery, the defaults fallback | | `MultiConfig` | type | Its config | | `TargetOutcome` | type | One fleet target's result, with its `source` | @@ -417,11 +420,11 @@ The package's `bin` entries, `github-settings-as-code` and `gsac`, run the same 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. An added name is a minor (a `feat` commit), and a rename or a removal is a major, listed in the [upgrading guide](../upgrading/README.md). -| npm dist-tag | Publishes on | Version | Install | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| `next` | Every refresh of the release PR: release-please creates or refreshes it when a releasable commit (feat, fix, perf, revert, or a breaking marker) lands on `main` | The manifest's next patch, then `-main...g`: `2.0.1-main.446.20260913.g95d081d` | `npm install @vivswan/github-settings-as-code@next` | -| `latest` | Every release cut | The released version: `2.1.0`. Until the first stable release it names the `0.0.0` placeholder that reserved the package name: a packument always carries `latest` (npm/registry REGISTRY-API.md, "dist-tags: an object with at least one key, latest"), so the first publish took it, and the first stable release moves it; ask for `@next` until then | `npm install @vivswan/github-settings-as-code` | -| none | Every green push to `main` (`build/.`, the ten newest kept) and every release tag | The commit itself | `npm install github:Vivswan/github-settings-as-code#` | +| npm dist-tag | Publishes on | Version | Install | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `next` | Every refresh of the release PR: release-please creates or refreshes it when a releasable commit (feat, fix, perf, revert, or a breaking marker) lands on `main` | The manifest's next patch, then `-main...g`: `2.0.1-main.446.20260913.g95d081d` | `npm install @vivswan/github-settings-as-code@next` | +| `latest` | Every release cut | The released version: `2.1.0` | `npm install @vivswan/github-settings-as-code` | +| none | Every green push to `main` (`build/.`, the ten newest kept) and every release tag | The commit itself | `npm install github:Vivswan/github-settings-as-code#` | The npm dist-tag `latest` is not the git tag `latest`: the git tag names the packaged commit of the newest `main` commit (every green push moves it forward, never back), the dist-tag names the newest release on the registry. diff --git a/docs/reference/secrets-and-vaults.md b/docs/reference/secrets-and-vaults.md index c0148b16..bb231a62 100644 --- a/docs/reference/secrets-and-vaults.md +++ b/docs/reference/secrets-and-vaults.md @@ -86,14 +86,14 @@ Here the vault step exports `WEBHOOK_SECRET` into the job environment, and the s ## What happens at run time -In apply mode, the action resolves every declared reference up front, after the read-only preflight and before the first write of any section. Resolution failures fail the repository cleanly with zero mutations: +In apply mode, the action resolves up front every reference declared in a section the run applies: before the first write of any section, and after the read-only preflight when `on-missing-permission: fail` runs one. A section the `sections` input excludes is not applied, so its references are never resolved. Resolution failures fail the repository cleanly with zero mutations: - An UNSET variable fails, naming the reference and pointing at the step's `env` block. - A SET-BUT-EMPTY variable also fails. An empty vault lookup must not write an empty secret, so a failed lookup cannot pass silently. Every resolved plaintext is registered with the runner's secret masker before it is used anywhere. That is on top of the protections the client always applies to secret-bearing requests: -- **Debug traces** show `***` in place of the value. +- **Debug traces** withhold the whole payload of a request that carries a resolved secret: `payload: `. - **The response body of a failed secret-carrying request** is withheld wholesale, since an error body can echo the rejected value. The value itself never appears in a drift line, a change line, a note, or a report. diff --git a/docs/reference/semantics.md b/docs/reference/semantics.md index 08c57a13..40ccc2a4 100644 --- a/docs/reference/semantics.md +++ b/docs/reference/semantics.md @@ -18,8 +18,7 @@ For a ruleset or an environment, apply refuses that write: the entry fails with Declare the key to keep the value, or declare it empty (`bypass_actors: []`, `reviewers: []`, `deployment_branch_policy: null`) to remove it on purpose. Empty values (an empty list, a zero, false, null) never count. A ruleset's `target` and `enforcement` never count either: an entry without them is parsed with `branch` and `active`, so a live value under either key is compared, not reported as omitted. -The sweep stops where the settings schema stops naming keys, inside `rules[].parameters` and a bypass actor's own fields, because there it cannot tell a default GitHub filled from a value the file left out. -A live `require_code_owner_review: true` beside a declared `pull_request` rule reads clean, and the PUT resets it. +In a ruleset the sweep stops inside a rule (its `parameters` included) and inside a bypass actor: each is a mapping that passes unknown keys through. A live `require_code_owner_review: true` beside a declared `pull_request` rule reads clean, and the PUT resets it. Apply is convergent: re-running preserves the declared state (some sections diff first and skip converged writes, others send idempotent full-payload writes), and a check right after an apply reports clean. diff --git a/docs/schema.docs.yml b/docs/schema.docs.yml index 7586f3b5..b23090c6 100644 --- a/docs/schema.docs.yml +++ b/docs/schema.docs.yml @@ -7,7 +7,8 @@ schema: `shallow` unions the entries by the section's key and swaps a same-key entry for this document's; `deep` unions by key and merges a same-key pair field by field, a nested keyed list (a ruleset's rules, by type) unioning the same way. Consumed by the render, never applied: - the rendered document carries no `_layering`, and apply and check ignore it like any `_` key. + the rendered document carries no `_layering`, and apply and check ignore it. It and `_undeclared` + are the only underscore keys a document's top level takes: any other one is refused. SettingsFile._undeclared: >- The file-wide undeclared policy: what apply does to a live resource a list does not declare, for every list in this document that takes the `_undeclared` knob (the knobbed sections and an diff --git a/docs/start/cli.md b/docs/start/cli.md index 506e1e81..33a6412d 100644 --- a/docs/start/cli.md +++ b/docs/start/cli.md @@ -10,20 +10,22 @@ start managing a repository from its live settings (`init`), validate a settings ## Install -Run it without installing, with npm or bun. Until the first stable release publishes the package, ask for the `next` tag: +Run it without installing, with npm or bun: ```bash -npx @vivswan/github-settings-as-code@next --help -bunx @vivswan/github-settings-as-code@next --help +npx @vivswan/github-settings-as-code --help +bunx @vivswan/github-settings-as-code --help ``` Or install it and call `gsac`: ```bash -npm install --global @vivswan/github-settings-as-code@next +npm install --global @vivswan/github-settings-as-code gsac --help ``` +Each form fetches the released version. Append `@next` to the package name for the pre-release of `main`; [Versioning](../reference/library.md#versioning) says which build that is. + The package needs Node 22.14 or newer; `bunx` fetches it the same way and honors the executable's `node` shebang. The command reads its token from `--token` or the `GITHUB_TOKEN` environment variable; the same fine-grained PAT the action uses (see [Token permissions](../reference/permissions.md)). ## Commands diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index 250bdfc3..54c2790b 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -4,7 +4,7 @@ order: 20 # Upgrading from v2 to v3 -Sixty-two breaks. Twenty-seven are for library consumers (sections 9, 22 to 29, 34, 35, 37 to 41, 45, 47, 49, 51, 54 to 56, 58, 60, 61, and 62), ten of those also refusals below, and two are for anyone pinning a sha or installing `@next` (sections 21 and 59). +Sixty-three breaks. Twenty-eight are for library consumers (sections 9, 22 to 29, 34, 35, 37 to 41, 45, 47, 49, 51, 54 to 56, 58, and 60 to 63), ten of those also refusals below, and two are for anyone pinning a sha or installing `@next` (sections 21 and 59). Eighteen are parse-time refusals (sections 36 to 52 and 55): a declaration GitHub would reject, or that could never converge, now fails before any request. Sections 53 and 57 are silent: YAML merge keys resolve, and one validation message is respelled. @@ -12,7 +12,7 @@ The library sections describe the public entry, `@vivswan/github-settings-as-cod The silent ones include the fallback, the renamed `GSAC_RETRY_BASE_MS`, the rendered file (it reorders once), and the snapshot file (it reorders once and no longer dates itself). Run `mode: check` before the first v3 apply and diff the first v3 rendered file. -The changelog entry for 3.0.0 will carry the release-please footers in the [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md). +The changelog entry for 3.0.0 carries the release-please footers in the [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md). ## Run inputs and outputs @@ -122,6 +122,7 @@ The detail is in [v2-to-v3/library.md](v2-to-v3/library.md). | Library: `GitHubClient` and `ArtifactUploader` answer, never reject | `tryRequest()` and `tryGraphql()` rejected for a request with no HTTP answer (not sent, the transport failed, a GraphQL body off the wire contract); `upload()` rejected to report a failed upload | Both port methods resolve to a `ClientAnswer` whose third arm is `{ failed }`, the whole line; `upload()` resolves to `{ uploaded: true }` or `{ failed }`. A test double that still throws is read as a broken contract: the failure is reported, never classified | A double returning `void` from `upload()`, or a caller reading `.data` off a `ClientAnswer` without narrowing `failed`, fails to compile; [section 58](v2-to-v3/library.md#58-library-githubclient-and-artifactuploader-answer-never-reject) | | Library: a target carries its parsed `RepoRef` | `CentralTarget` and `RemoteTarget` carried `slug: string`, and `parseReposInput` resolved to `{ slugs: string[], discover }` | Both carry `repo: RepoRef` (`owner`, `name`, `slug`), parsed once where the input is read; `parseReposInput` resolves to `{ repos: RepoRef[], discover }` | Reading `.slug` off a target or `.slugs` off the parsed input fails to compile (`TS2339`); read `target.repo.slug` and `parsed.repos`; [section 62](v2-to-v3/library.md#62-library-a-target-carries-its-parsed-reporef) | | Library: `GitHubClient` lists through `tryList()` | The verbs walked a list page by page through `tryRequest("GET", path?per_page=100&page=N)` and stopped on a short page | The port has a third member, `tryList(path, {perPage, until})`, resolving to a `ClientAnswer` of page bodies; `GitHubApi` follows GitHub's `Link` header through `@octokit/plugin-paginate-rest` | A double or client without `tryList` fails to compile, naming the missing member; [section 60](v2-to-v3/library.md#60-library-githubclient-lists-through-trylist) | +| Library: `SettingsFile` applies a section schema as the action does | In the pre-release v3 builds `SettingsFile.parse` dropped a label's undeclared `owner` and the refused `actions.selected_actions_url` without a word, so both documents parsed | A key the action passes through survives the parse, and a key the section's schema refuses fails it with the key named; the checks beside the schema (unknown sections, a closed section's entry keys, duplicates) stay with `validateSettings` | A document that parsed fails naming the refused key, and a parsed label carries the `owner` it used to lose; [section 63](v2-to-v3/library.md#63-library-settingsfile-applies-a-section-schema-as-the-action-does) | ## Releases and tags diff --git a/docs/upgrading/v2-to-v3/library.md b/docs/upgrading/v2-to-v3/library.md index 865e4393..eb26e19b 100644 --- a/docs/upgrading/v2-to-v3/library.md +++ b/docs/upgrading/v2-to-v3/library.md @@ -5,7 +5,7 @@ title: "Library API" # Upgrading from v2 to v3: library API -Sections 9, 23, 27, 35, 54, 56, 58, and 60 of the [v2 to v3 guide](../v2-to-v3.md), whose index page carries the summary table and the order of operations. +Sections 9, 23, 27, 35, 54, 56, 58, 60, 62, and 63 of the [v2 to v3 guide](../v2-to-v3.md), whose index page carries the summary table and the order of operations. ## 9. Library: the public entry and the v3 names @@ -214,3 +214,25 @@ v3 const { repos } = parseReposInput(input)._unsafeUnwrap(); ``` Fix: read `target.repo.slug` (or `owner` and `name`) where you read `target.slug`, and build a `RemoteTarget` from each `RepoRef` in `parsed.repos`. + +## 63. Library: `SettingsFile` applies a section schema as the action does + +For `@vivswan/github-settings-as-code` consumers. The old form is the pre-release v3 builds', as in section 56. + +- **A key the action passes through survives the parse.** The pre-release builds dropped a label's undeclared `owner` without a word. +- **A key the section's schema refuses fails the parse,** naming the key. The pre-release builds dropped `actions.selected_actions_url`, so the document parsed. +- **The checks beside the schema stay with `validateSettings`:** an unknown top-level section, a closed section's unknown entry key, a duplicate entry, a secret value's form, the keys a wildcard branch entry may carry. + +```text +pre-release SettingsFile.safeParse({ labels: [{ name: "bug", color: "d73a4a", owner: "platform" }] }) + // success: {"labels":[{"name":"bug","color":"d73a4a"}]} + SettingsFile.safeParse({ actions: { enabled: true, selected_actions_url: "https://api.github.com/x" } }) + // success: {"actions":{"enabled":true}} + +v3 SettingsFile.safeParse({ labels: [{ name: "bug", color: "d73a4a", owner: "platform" }] }) + // success: {"labels":[{"name":"bug","color":"d73a4a","owner":"platform"}]} + SettingsFile.safeParse({ actions: { enabled: true, selected_actions_url: "https://api.github.com/x" } }) + // failure: actions.selected_actions_url: selected_actions_url is a value GitHub reports, not a setting it accepts (...), so a declared value could never be applied; remove it from the settings file +``` + +Fix: remove or move each key the parse names. Code that leaned on the parse to drop a key drops it itself. diff --git a/test/docs/readme.test.ts b/test/docs/readme.test.ts index 47943caf..a01c6d3e 100644 --- a/test/docs/readme.test.ts +++ b/test/docs/readme.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import type { z } from "zod"; import { countWord } from "../../.github/scripts/lib/count-word.js"; import { RUN_RESULTS } from "../../src/engine/outcome.js"; import { DEFAULT_PRIVATE_REPOS, INPUT_DECLS } from "../../src/flows/inputs.js"; @@ -10,14 +11,20 @@ import { SNAPSHOT_SCHEMA_URL } from "../../src/flows/snapshot.js"; import { ARTIFACT_FILE, ARTIFACT_NAME } from "../../src/report/artifact-report.js"; import { PRIVATE_REPORT_CHANNELS } from "../../src/report/delivery.js"; import { ISSUE_REPORT_PERMISSION } from "../../src/report/issue-report.js"; -import { PROBOT_PARITY_KEYS, SECTION_KEYS } from "../../src/schema.js"; +import { + PROBOT_PARITY_KEYS, + SECTION_KEYS, + type SectionKey, + SettingsFile, +} from "../../src/schema.js"; import { grantFor } from "../../src/sections/contract/permissions.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { SECTIONS } from "../../src/sections/registry.js"; +import { schemaNode } from "../../src/sections/shared/schema-node.js"; import { ROOT } from "../root.js"; import { readSettingsSchema } from "../settings-schema.js"; import { defaultClaimProblems, deleteEnumerationProblems } from "./claims.js"; -import { fencedBlocks, sectionLines } from "./markdown.js"; +import { fencedBlocks, sectionLines, tableRows } from "./markdown.js"; import { assertValidSettingsExample } from "./settings-examples.js"; import { stalePins } from "./version-pins.js"; @@ -266,17 +273,55 @@ describe("SettingsFile deletion claims", () => { }); }); -describe("forward-compatibility closed-sections claim", () => { - test("the guide's prose names exactly the closedSurface sections", () => { - // closedSurface is the single source of which sections reject unrecognized keys. The page's title is a single `#`, so it is read whole-document. - const closed = SECTIONS.filter((section) => section.closedSurface !== undefined).map( - (section) => section.key, - ); +/** + * Every mapping a schema closes, by dotted path (a list item spelled `[]`), outermost only: a strict mapping's own + * strict children are part of it. A knob's wrapper holds this action's vocabulary, never GitHub's, so only its list is read. + */ +function strictMappings(schema: z.ZodType, path: string): string[] { + const node = schemaNode(schema); + switch (node.kind) { + case "object": + return node.openness === "strict" + ? [path] + : Object.entries(node.shape).flatMap(([key, child]) => + strictMappings(child, `${path}.${key}`), + ); + case "array": + return strictMappings(node.element, `${path}[]`); + case "record": + return strictMappings(node.value, `${path}.*`); + case "wrapper": + return strictMappings(node.inner, path); + case "union": + return node.knob === null + ? node.options.flatMap((option) => strictMappings(option, path)) + : strictMappings(node.knob.list, path); + case "leaf": + return []; + } +} + +/** The two ways a section refuses an unknown key (its closedSurface, a strict mapping at its root), and every outermost strict mapping below a root. */ +function closedShapes(): { sections: SectionKey[]; nested: string[] } { + const strict = new Set( + SECTION_KEYS.flatMap((key) => strictMappings(SettingsFile.shape[key], key)), + ); + const sections = SECTIONS.filter( + (section) => section.closedSurface !== undefined || strict.has(section.key), + ).map((section) => section.key); + const roots = new Set(SECTION_KEYS); + return { sections, nested: [...strict].filter((path) => !roots.has(path)) }; +} + +describe("forward-compatibility closed-shapes claims", () => { + // The page's title is a single `#`, so the sentence is read whole-document. + const source = "docs/reference/forward-compatibility.md"; + const page = readFileSync(join(ROOT, source), "utf8"); + + test("the guide's prose names exactly the sections that refuse an unknown key, a strict root as much as a closedSurface", () => { + const closed = closedShapes().sections; expect(closed.length).toBeGreaterThan(0); - const paragraph = readFileSync( - join(ROOT, "docs", "reference", "forward-compatibility.md"), - "utf8", - ).replace(/\n/g, " "); + const paragraph = page.replace(/\n/g, " "); const sentence = paragraph.match(/[^.]*closed rather than passthrough[^.]*\./)?.[0]; expect( sentence, @@ -294,4 +339,13 @@ describe("forward-compatibility closed-sections claim", () => { } } }); + + test("the nested-shapes table names exactly the outermost strict mappings below a section's root, so one a slice adds never stays off the page", () => { + const { nested } = closedShapes(); + expect(nested.length).toBeGreaterThan(0); + const [, ...rows] = tableRows( + sectionLines(page, "The closed nested shapes", source).join("\n"), + ); + expect(rows.map(([shape]) => shape).sort()).toEqual(nested.map((path) => `\`${path}\``).sort()); + }); });