diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acacbea7..efe80fa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all - `bun run build:openapi` (`.github/scripts/gen-openapi.ts`) renders the files the descriptor dictates, the invitation role enums in `src/generated/spec-roles.ts`, the ruleset rule rows in `src/generated/spec-rules.ts`, and the passthrough vocabularies and bodies in `src/generated/spec-enums.ts`; a descriptor bump regenerates them and `test/scripts/gen-openapi.test.ts` fails on a committed copy that drifted. - `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. +- `bun run build:check` regenerates every table entry and fails on drift. Before that, it fails on a GENERATED marker, in a file the table lists, that names a region no generator registers. - `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. diff --git a/README.md b/README.md index 26fd03ab..a87e2cdf 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state color: "d73a4a" ``` -3. Add the workflow and run it from the Actions tab. Keep `mode: check` for the first run: the drift report lists everything an apply would change or delete, and nothing is written. Read the report: an apply deletes undeclared labels, autolinks, collaborators, Actions variables, and Copilot agents variables. Drop the `mode: check` line once the report says what you expect; [Run check mode first](docs/start/getting-started.md#4-run-check-mode-first) explains the drift output. +3. Add the workflow and run it from the Actions tab. Keep `mode: check` for the first run: the drift report lists what an apply would change or delete, and nothing is written. Read the report: an apply deletes undeclared labels, autolinks, collaborators, Actions variables, and Copilot agents variables. Drop the `mode: check` line once the report says what you expect; [Run check mode first](docs/start/getting-started.md#4-run-check-mode-first) explains the drift output. + + The report cannot list the value of a declared secret that already exists, which GitHub never reads back: every apply rewrites it, and a check shows a [cannot verify note](docs/operate/check-mode.md#what-a-cannot-verify-note-means) for it, not drift. ```yaml # .github/workflows/settings.yml diff --git a/docs/operate/layering.md b/docs/operate/layering.md index cd52ba7f..85d62c55 100644 --- a/docs/operate/layering.md +++ b/docs/operate/layering.md @@ -343,7 +343,7 @@ The rendered file is exactly what apply runs, so it is worth knowing its shape: An environment's variables, secrets, deployment branch policies, and deployment protection rules are in their `{_undeclared, entries}` wrapper form too, each policy resolved; its reviewers take no knob and stay as declared. - No `_layering` and no top-level `_undeclared` anywhere: both directives are consumed before the file is written, and YAML comments do not survive the fold. - Every `null` that won its key is there, at the top level (`pages: null`, `interaction_limits: null`) or below: the fold reads no `null` as a marker, and validation refused every one a key does not admit. -- No `_remove` anywhere: each removal was consumed at its own layer's step, along with the entry it named. +- No `_remove` on a keyed entry: each removal was consumed at its own layer's step, along with the entry it named. One on an entry of a list that is not keyed removes nothing and stays, a field [under a warning](#four-knobs). - Every layer was validated on its own before the fold, and the result is validated again before it is written. ## The two-step workflow @@ -440,7 +440,7 @@ That guarantee covers the fold alone. The per-layer validation prints the same m - A `branches` control declared as a scalar where a mapping belongs, on a literal branch and a wildcard entry alike, naming the key and nothing of the value: `branches[0].protection.required_status_checks: required_status_checks must be a mapping of its keys (strict, then contexts or checks), or null to turn the requirement off`. - A YAML syntax error, quoting the offending source line with a caret under the column; an unresolved alias names the alias instead, and an anchor whose name carries whitespace or a control character prints that name after `Anchor must not contain whitespace or control characters:`. -- A YAML alias inside the node its own anchor opens (`repository: &loop {self: *loop}`), which would parse to a cyclic object, names the anchor when the file is read, before any validation: `alias *loop refers back into the node its anchor &loop opens (a YAML alias cycle), which no settings document can carry. An alias may only reference a completed node: write the value out again, or move the anchor to a sibling`. +- A YAML alias cycle (`repository: &loop {self: *loop}`), which would parse to a cyclic object, names the anchor when the file is read, before any validation: `alias *loop refers back into the node its anchor &loop opens (a YAML alias cycle), which no settings document can carry. An alias may only reference a completed node: write the value out again, or move the anchor to a sibling`. - A YAML parser warning (an unresolved tag, an unknown directive, an ambiguous anchor, a collection used as a key) prints nothing: the parser runs with its warnings off, so such a document parses silently to the same object it always did, and only a parse failure reaches the log, printing what the bullet above describes. A render-mode log can therefore show your settings file's structure and, through these messages, a value from it: treat it like any log that prints a parse error for a file the runner holds. diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index 7b81ce0c..a4d20223 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -201,10 +201,10 @@ Their notes are in their file header; there is no report channel in snapshot mod A denial is a read the token's grants refuse. Where it lands decides what `on-missing-permission` does with it: -| Denied read | `fail` (default) | `warn` | -| ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| A section's main read (the labels list, the Actions permissions) | The section fails; the run fails with exit 1 and writes no file | The section is skipped with a warning and listed in `skipped-sections`; the rest is written | -| A key the section reads on its own (an `actions` key such as the OIDC template or a cache limit; an environment's branch policies or protection rules) | The same: the section fails and no file is written | That key alone is left out under a header line; the rest of its section reads back | +| Denied read | `fail` (default) | `warn` | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| A section's main read (the labels list, the Actions permissions) | The section fails; the run fails with exit 1 and writes no file | The section is skipped with a warning and listed in `skipped-sections`; the rest is written | +| A key the section reads on its own (an `actions` key such as the OIDC template or a cache limit; an environment's branch policies or protection rules; the `interaction_limits` creation cap or bypass list) | The same: the section fails and no file is written | That key alone is left out under a header line; the rest of its section reads back | Under both policies the error or the header line names the grant to add. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 6cca5e56..db1a0109 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -131,7 +131,9 @@ In a multi-repo run the `defaults-file` document is validated before target reso - Two document-wide walks: a value that is not plain YAML data (a tagged mapping, a list with a hole) and a passthrough number that is not finite. Their proof is the `ProvedPlain` brand on the walk's output, the only input the `ValidatedSettings` mint accepts. -Every issue the phase finds lands in one list: unknown directives, unknown sections, a single document's `_remove` markers, then each section's issues in apply order. Zero section requests reach that repository, and the run exits 1; in a multi-repo run only that target fails. One downgrade: an unknown section outside a non-empty `sections` allowlist is a warning, so an older action can run a file written for a newer one. +Every issue the phase finds lands in one list: unknown directives, unknown sections, a single document's `_remove` markers, then each section's issues in apply order. Zero section requests reach that repository, and the run exits 1; in a multi-repo run only that target fails. + +Three downgrades, each a warning in place of an issue. An unknown section outside a non-empty `sections` allowlist, so an older action can run a file written for a newer one. And, in a document that otherwise validates, until the next major refuses them: a single document's top-level `_layering` outside its three values, and a `_remove` on an entry of a list that is not keyed. Two limits. A shape's own issues are capped at five per section, with a count of the rest; and a section's hook runs once its shape parsed, so a shape error in an entry can hide a duplicate until it is fixed. @@ -168,7 +170,7 @@ flowchart TD whole --> out ``` -The fold is a cascade: the higher layer's value wins at every depth, and `null` is a value. Every list section is keyed by what its planner matches entries by, so a fleet `Bug` and a repository `bug` are one label. Three directives say how a keyed list meets the list below it; here the highest layer, in a run with `layering: replace`: +The fold is a cascade: the higher layer's value wins at every depth (a label's `name` aside: [the rename rule](../operate/layering.md#the-rules)), and `null` is a value. Every list section is keyed by what its planner matches entries by, so a fleet `Bug` and a repository `bug` are one label. Three directives say how a keyed list meets the list below it; here the highest layer, in a run with `layering: replace`: ```yaml layer _layering: shallow # every list section of this file, unless it says otherwise diff --git a/docs/reference/coverage.md b/docs/reference/coverage.md index d0af47b1..a73a779f 100644 --- a/docs/reference/coverage.md +++ b/docs/reference/coverage.md @@ -476,6 +476,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration - GET/POST /repos/{owner}/{repo}/actions/variables and PATCH/DELETE /repos/{owner}/{repo}/actions/variables/{name}: plain-text repository variables, upserted by name. - Names are case-insensitive (GitHub stores them uppercased regardless of how they are entered), so matching and duplicate rejection compare uppercased names. - Values read back in full, which is what makes exact check-mode diffing possible; undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. +- A declared created_at or updated_at, which the GET reports and neither write takes, is named by a note in check and in apply, whatever the live variable holds. - The list endpoint caps per_page at 30 (not the usual 100), which the page loop honors so a large inventory is never truncated. - Secrets are write-only material and deliberately NOT this section: they live in the `actions_secrets` section above. - Snapshot reads the variables back as name and value entries under the delete default. @@ -485,6 +486,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration - GET/POST /repos/{owner}/{repo}/agents/variables and PATCH/DELETE /repos/{owner}/{repo}/agents/variables/{name}: same upsert-by-uppercase-name semantics, exact check-mode diffing, and 30-item per_page cap as `actions_variables`, over the Copilot agents variable store. - GET .../agents/organization-variables is the read-only org-inherited view, out of scope. - Undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. +- A declared created_at or updated_at, which the GET reports and neither write takes, is named by a note in check and in apply, whatever the live variable holds. - CAVEAT: GitHub auto-migrated values from the older `copilot` Actions environment into this store, so a repository can hold agents variables nobody created here - run `mode: check` first (it lists them as deletion drift) before the first apply, or declare `_undeclared: keep`. - Snapshot reads the variables back as name and value entries under the delete default. diff --git a/docs/reference/library.md b/docs/reference/library.md index f2c22c7e..bb3b9b2c 100644 --- a/docs/reference/library.md +++ b/docs/reference/library.md @@ -62,25 +62,25 @@ The examples continue from one another and form one program (the docs tests comp ### Validate and merge -| Name | Kind | Says | -| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `validateSettings` | function | Validate a parsed document into the branded `ValidatedSettings` every other verb takes; a `null` is a declared value, refused where the key has no empty state | -| `ValidateOptions` | type | `source`, `sections`, `io`, `undeclared` (the action's input of that name: the fallback policy below a list's wrapper and the file's top-level `_undeclared`), `secretSource` (who authored the document: `operator`, the default, honors `$NAME` secret references; `target` refuses them) | -| `ValidateReport` | type | `settings` and `log` | -| `ValidatedSettings` | type | The document as zod parsed it, branded by validation, every knobbed list in `{_undeclared, entries}` form with its policy resolved; `validateSettings`, `mergeSettings`, and a snapshot that did not fail hand one out | -| `mergeSettings` | function | Fold an ordered list of `Layer`s into one validated document, as `mode: render` does: each layer validated alone, folded, validated again; its `yaml` is byte for byte the file `mode: render` writes, the fold in the canonical order every rendered document shares (sections in execution order, keys as the schema declares them, the entries of every list section by identity; `branches`, `bypass_actors`, `reviewers`, and scalar lists as written), so no layer's key order reaches the file | -| `MergeOptions` | type | `source`, `layering` (`"deep"` unless set), `undeclared` (unset unless given), `io` | -| `MergeReport` | type | `settings`, `notices` (one per `_remove: true` entry that dropped a lower entry), `yaml` (the file text `mode: render` writes, byte for byte), and `log` (the lines the fold printed) | -| `Layer` | type | One layer: its `name` (a path, usually) and its parsed `doc` | -| `Layering` | type | `"replace"`, `"shallow"`, or `"deep"`: how every list section's entries fold across layers, by the section's key | -| `RemovalNotice` | type | A `_remove: true` entry that dropped what a lower layer declared: the layer and the entry's path | -| `describeRemoval` | function | One notice as the line the action prints | -| `readLayerFiles` | function | Read paths into `Layer`s in order; the first unreadable file is the problem | -| `readSettingsFile` | function | Read and parse one YAML file, given its role (`settings-file`, `defaults-file`, `layer`, `central-file`), so the problem's advice fits | -| `SettingsFileRole` | type | The four roles | -| `parseSettingsDoc` | function | Parse YAML text into an unknown document | -| `Problem` | type | Every typed failure a call can return, keyed by `code` | -| `describeProblem` | function | A `Problem` as the message the action prints | +| Name | Kind | Says | +| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `validateSettings` | function | Validate a parsed document into the branded `ValidatedSettings` every other verb takes; a `null` is a declared value, refused where the key has no empty state | +| `ValidateOptions` | type | `source`, `sections`, `io`, `undeclared` (the action's input of that name: the fallback policy below a list's wrapper and the file's top-level `_undeclared`), `secretSource` (who authored the document: `operator`, the default, honors `$NAME` secret references; `target` refuses them) | +| `ValidateReport` | type | `settings` and `log` | +| `ValidatedSettings` | type | The document as zod parsed it, branded by validation, every knobbed list in `{_undeclared, entries}` form with its policy resolved; `validateSettings`, `mergeSettings`, and a snapshot that did not fail hand one out | +| `mergeSettings` | function | Fold an ordered list of `Layer`s into one validated document, as `mode: render` does: each layer validated alone, folded, validated again; its `yaml` is byte for byte the file `mode: render` writes, the fold in the canonical order every rendered document shares (sections in execution order, keys as the schema declares them, the entries of every list section by identity; `branches`, the pinned environments, `bypass_actors`, `reviewers`, and scalar lists as written), so no layer's key order reaches the file | +| `MergeOptions` | type | `source`, `layering` (`"deep"` unless set), `undeclared` (unset unless given), `io` | +| `MergeReport` | type | `settings`, `notices` (one per `_remove: true` entry that dropped a lower entry), `yaml` (the file text `mode: render` writes, byte for byte), and `log` (the lines the fold printed) | +| `Layer` | type | One layer: its `name` (a path, usually) and its parsed `doc` | +| `Layering` | type | `"replace"`, `"shallow"`, or `"deep"`: how every list section's entries fold across layers, by the section's key | +| `RemovalNotice` | type | A `_remove: true` entry that dropped what a lower layer declared: the layer and the entry's path | +| `describeRemoval` | function | One notice as the line the action prints | +| `readLayerFiles` | function | Read paths into `Layer`s in order; the first unreadable file is the problem | +| `readSettingsFile` | function | Read and parse one YAML file, given its role (`settings-file`, `defaults-file`, `layer`, `central-file`), so the problem's advice fits | +| `SettingsFileRole` | type | The four roles | +| `parseSettingsDoc` | function | Parse YAML text into an unknown document | +| `Problem` | type | Every typed failure a call can return, keyed by `code` | +| `describeProblem` | function | A `Problem` as the message the action prints | ```ts import { diff --git a/docs/reference/semantics.md b/docs/reference/semantics.md index 40ccc2a4..a96bcc28 100644 --- a/docs/reference/semantics.md +++ b/docs/reference/semantics.md @@ -13,14 +13,16 @@ Three writes carry the whole object, the ruleset PUT, the environment PUT, and t Check reports each one as drift naming the key (`bypass_actors`, `conditions.ref_name.exclude`, `reviewers`, `required_status_checks`). -For a ruleset or an environment, apply refuses that write: the entry fails with the same line and nothing of it is written, until the file says which is meant. +For a ruleset or an environment, apply refuses that write: the entry fails with the same line and nothing of it is written, until the file says which is meant. The section stops at that entry: the operations its plan still held are never sent, the run lists each as `not attempted` under a warning that counts them, and the other sections still run. 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. 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. +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 wherever that apply reached the declared state. + +Three examples of a declared state no operation of the plan reaches: a `workflows` entry whose file does not exist (`so apply skips it`), a `pull_request_creation_cap` where the cap endpoint answers 405 (`apply cannot set it`), and a `pages` section with neither `build_type` nor `source` and no live site (`so apply changes nothing`). Apply prints the line as a note, the next check as drift. ## What the parse refuses @@ -35,6 +37,8 @@ The field GitHub reports but cannot set is the case that motivated the rule: | Before | The GET reports `has_downloads`, the PATCH cannot set it, so every check saw drift and every apply re-sent it without converging. | | Now | The parse refuses the file with an error naming `repository.has_downloads` and telling you to remove the key. No section reads or writes the repository. | +Three passthrough entry shapes name such a key in a note instead, until the next major refuses it there: an environment entry, one of its `deployment_branch_policies` patterns, and a variable (`actions_variables`, `agents_variables`, an environment's `variables`). The key still parses and rides the write, and the note, drawn from the declaration alone in check and in apply, says to remove it. + An unknown key has two fates, decided by the shape it sits in: | Shape | Unknown key | What you see | diff --git a/docs/schema.docs.yml b/docs/schema.docs.yml index b23090c6..36591bf5 100644 --- a/docs/schema.docs.yml +++ b/docs/schema.docs.yml @@ -3,12 +3,17 @@ schema: SettingsFile._layering: >- mode: render only: a layer-level default for how this document's list sections combine with the layers below it, overriding the run's layering input; a section's own wrapper-level - `_layering` overrides it again. `replace` lets each of this document's lists win wholesale; + `_layering` overrides it again. + + `replace` lets each of this document's lists win wholesale; `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. It and `_undeclared` - are the only underscore keys a document's top level takes: any other one is refused. + 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 do not read it. On a single document they warn when its value is none of the + three, and the next major version refuses it there. 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/sections/actions_variables.docs.yml b/docs/sections/actions_variables.docs.yml index e0beaaf2..8fce999f 100644 --- a/docs/sections/actions_variables.docs.yml +++ b/docs/sections/actions_variables.docs.yml @@ -17,6 +17,9 @@ coverage: - >- Values read back in full, which is what makes exact check-mode diffing possible; undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. + - >- + A declared created_at or updated_at, which the GET reports and neither write takes, is named + by a note in check and in apply, whatever the live variable holds. - >- The list endpoint caps per_page at 30 (not the usual 100), which the page loop honors so a large inventory is never truncated. diff --git a/docs/sections/agents_variables.docs.yml b/docs/sections/agents_variables.docs.yml index 4af0eb40..48f76eb7 100644 --- a/docs/sections/agents_variables.docs.yml +++ b/docs/sections/agents_variables.docs.yml @@ -14,6 +14,9 @@ coverage: - >- Undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. + - >- + A declared created_at or updated_at, which the GET reports and neither write takes, is named + by a note in check and in apply, whatever the live variable holds. - >- CAVEAT: GitHub auto-migrated values from the older `copilot` Actions environment into this store, so a repository can hold agents variables nobody created here - run `mode: check` first diff --git a/docs/start/cli.md b/docs/start/cli.md index 6afe1dcc..25e5c1fb 100644 --- a/docs/start/cli.md +++ b/docs/start/cli.md @@ -142,12 +142,12 @@ Two inputs have no command-line form: `--private-report artifact` is refused (th Four flags are the command line's own, and `init` has one more, `--force` (replace an existing settings file): -| Flag | Does | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--token ` | The token; `GITHUB_TOKEN` when absent | -| `--json` | Print the outputs as one JSON object on stdout; log lines move to stderr | -| `--summary ` | Append the run's markdown summary (the action's step summary) to this file; under GitHub Actions the step summary itself when absent. The file is opened, and its directories created, before the run starts: a path that cannot be opened is refused before any request, and the empty file and its directories stay when the run is then refused for another reason. `init`, `validate`, and `permissions` never open it. | -| `--verbose` | Show the debug trace on stderr | +| Flag | Does | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--token ` | The token; `GITHUB_TOKEN` when absent | +| `--json` | Print the outputs as one JSON object on stdout; log lines move to stderr | +| `--summary ` | Append the run's markdown summary (the action's step summary) to this file; under GitHub Actions the step summary itself when absent. The file is opened, and its directories created, before the run starts: a path that cannot be opened is refused before any request. When the run is then refused for another reason, a file the open created stays, empty, with its directories, and a file that already existed keeps what it held. `init`, `validate`, and `permissions` never open it. | +| `--verbose` | Show the debug trace on stderr | ## Output and exit codes diff --git a/docs/upgrading/v2-to-v3.md b/docs/upgrading/v2-to-v3.md index 54c2790b..ce1b59c3 100644 --- a/docs/upgrading/v2-to-v3.md +++ b/docs/upgrading/v2-to-v3.md @@ -51,14 +51,14 @@ The detail is in [v2-to-v3/settings-file.md](v2-to-v3/settings-file.md). The detail is in [v2-to-v3/layering.md](v2-to-v3/layering.md). -| Break | v2 | v3 | What the old form does now | -| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Layering happens in `mode: render` | The only merge was the defaults-file one | An ordered list of files folds in a render step; apply and check take one final document | No error. Reach for the [two-step workflow](../operate/layering.md#the-two-step-workflow) to get merging back | -| The rendered file is the fold in the canonical order | The pre-release v3 builds wrote the validated parse, keys in the schema's order | The fold rendered in the one canonical order (sections in execution order, keys as the schema declares them, the entries of every keyed list by identity; `branches`, `bypass_actors`, `reviewers`, and scalar lists as written), byte for byte what `mergeSettings` returns as `yaml` | No error: a committed rendered file reorders once; [section 24](v2-to-v3/layering.md#24-the-rendered-file-is-the-fold-in-the-canonical-order) | -| `_layering` takes `replace`, `shallow`, or `deep`, and every list section layers by key | No layering; the pre-release v3 builds took `merge` or `replace`, and only `labels` and `rulesets` unioned | `deep` (default) unions by the section's key and merges a same-key pair field by field; `shallow` unions and swaps the pair; `replace` lets the higher list win; all sixteen knobbed sections union | `_layering: merge` and `layering: merge` fail as unknown values, naming the three; a fourteen-section overlay that relied on silent replacement now unions; [section 28](v2-to-v3/layering.md#28-_layering-takes-replace-shallow-or-deep) | -| `mode: merge` is `mode: render` | No render mode; the pre-release v3 builds spelled it `mode: merge`, `merged-file`, CLI `merge`, result `merged` | `mode: render`, `rendered-file`, CLI `render`, result `rendered`; five problem codes and four library flow exports carry the render spelling | `mode: merge` fails as an unsupported mode value; a `merged-file` input draws the runner's unknown-input warning; [section 29](v2-to-v3/layering.md#29-mode-merge-is-mode-render) | -| `environments`, `branches`, and `workflows` layer by key | A higher list replaced the lower one whole | Union by key under `deep`: environment names case-insensitively, branch names verbatim, workflow paths as GitHub lists them; each accepts a `{_layering, entries}` wrapper; an environment's nested lists union by their own keys | No error: a higher layer that meant to replace now unions; write `_layering: replace` on the wrapper; [section 30](v2-to-v3/layering.md#30-environments-branches-and-workflows-layer-by-key) | -| A higher layer's `null` wins and means empty on GitHub; `_remove: true` drops a keyed entry | In v2's defaults merge a whole-section `null` opted out of the defaults' section while a nested `null` survived as a value; the pre-release fold read a `null` as "delete the lower key" | `null` is written as the value, the empty or off state; a key with no empty state refuses it; a whole-section `null` only on `pages` and `interaction_limits`; `_remove: true` drops one lower entry and is consumed | Validation fails naming the legal values; a `null` written to mean "stop managing this" empties the setting on GitHub instead; [section 31](v2-to-v3/layering.md#31-null-wins-and-means-empty-and-_remove-drops-an-entry) | +| Break | v2 | v3 | What the old form does now | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Layering happens in `mode: render` | The only merge was the defaults-file one | An ordered list of files folds in a render step; apply and check take one final document | No error. Reach for the [two-step workflow](../operate/layering.md#the-two-step-workflow) to get merging back | +| The rendered file is the fold in the canonical order | The pre-release v3 builds wrote the validated parse, keys in the schema's order | The fold rendered in the one canonical order (sections in execution order, keys as the schema declares them, the entries of every keyed list by identity; `branches`, the pinned environments, `bypass_actors`, `reviewers`, and scalar lists as written), byte for byte what `mergeSettings` returns as `yaml` | No error: a committed rendered file reorders once; [section 24](v2-to-v3/layering.md#24-the-rendered-file-is-the-fold-in-the-canonical-order) | +| `_layering` takes `replace`, `shallow`, or `deep`, and every list section layers by key | No layering; the pre-release v3 builds took `merge` or `replace`, and only `labels` and `rulesets` unioned | `deep` (default) unions by the section's key and merges a same-key pair field by field; `shallow` unions and swaps the pair; `replace` lets the higher list win; all sixteen knobbed sections union | `_layering: merge` and `layering: merge` fail as unknown values, naming the three; a fourteen-section overlay that relied on silent replacement now unions; [section 28](v2-to-v3/layering.md#28-_layering-takes-replace-shallow-or-deep) | +| `mode: merge` is `mode: render` | No render mode; the pre-release v3 builds spelled it `mode: merge`, `merged-file`, CLI `merge`, result `merged` | `mode: render`, `rendered-file`, CLI `render`, result `rendered`; five problem codes and four library flow exports carry the render spelling | `mode: merge` fails as an unsupported mode value; a `merged-file` input draws the runner's unknown-input warning; [section 29](v2-to-v3/layering.md#29-mode-merge-is-mode-render) | +| `environments`, `branches`, and `workflows` layer by key | A higher list replaced the lower one whole | Union by key under `deep`: environment names case-insensitively, branch names verbatim, workflow paths as GitHub lists them; each accepts a `{_layering, entries}` wrapper; an environment's nested lists union by their own keys | No error: a higher layer that meant to replace now unions; write `_layering: replace` on the wrapper; [section 30](v2-to-v3/layering.md#30-environments-branches-and-workflows-layer-by-key) | +| A higher layer's `null` wins and means empty on GitHub; `_remove: true` drops a keyed entry | In v2's defaults merge a whole-section `null` opted out of the defaults' section while a nested `null` survived as a value; the pre-release fold read a `null` as "delete the lower key" | `null` is written as the value, the empty or off state; a key with no empty state refuses it; a whole-section `null` only on `pages` and `interaction_limits`; `_remove: true` drops one lower entry and is consumed | Validation fails naming the legal values; a `null` written to mean "stop managing this" empties the setting on GitHub instead; [section 31](v2-to-v3/layering.md#31-null-wins-and-means-empty-and-_remove-drops-an-entry) | ## Per-section behavior diff --git a/docs/upgrading/v2-to-v3/layering.md b/docs/upgrading/v2-to-v3/layering.md index d646e734..f927d411 100644 --- a/docs/upgrading/v2-to-v3/layering.md +++ b/docs/upgrading/v2-to-v3/layering.md @@ -177,7 +177,7 @@ The nested `variables` list renders bare above because both layers wrote it bare ## 31. null wins and means empty, and _remove drops an entry -The fold is a cascade: the higher layer's value wins at every depth, and `null` is a value like any other. Our `null` is the EMPTY or OFF state on GitHub, never a marker that deletes a lower key. +The fold is a cascade: the higher layer's value wins at every depth (a label's `name` aside: [the rename rule](../../operate/layering.md#the-rules)), and `null` is a value like any other. Our `null` is the EMPTY or OFF state on GitHub, never a marker that deletes a lower key. ```text fleet.yml (lower) labels: diff --git a/docs/upgrading/v2-to-v3/run.md b/docs/upgrading/v2-to-v3/run.md index 9c64135a..321f7cb6 100644 --- a/docs/upgrading/v2-to-v3/run.md +++ b/docs/upgrading/v2-to-v3/run.md @@ -196,7 +196,7 @@ v3 apply rulesets[main-protection]: not applied - the update would remove a l A file that was stable can now drift: an environment entry declaring `variables` alone, beside a live wait timer, reports the timer in check and refuses the PUT in apply. Declare `wait_timer` to keep it, or `wait_timer: 0` to remove it; an omitted object whose key accepts null offers `: null` (`deployment_branch_policy: null`). -The sweep stops where the entry shape stops naming keys (`rules[].parameters`, the fields inside a bypass actor): a live non-default rule parameter beside a declared rule still reads clean and the PUT resets it, as before. The [semantics page](../../reference/semantics.md) states the boundary. +The sweep stops where the entry shape stops naming keys (inside a ruleset's rule, its `parameters` included, and inside a bypass actor): a live non-default rule parameter beside a declared rule still reads clean and the PUT resets it, as before. The [semantics page](../../reference/semantics.md) states the boundary. ## 34. File-only checks run before the first write diff --git a/docs/upgrading/v2-to-v3/settings-file.md b/docs/upgrading/v2-to-v3/settings-file.md index 690893a4..87ad439d 100644 --- a/docs/upgrading/v2-to-v3/settings-file.md +++ b/docs/upgrading/v2-to-v3/settings-file.md @@ -35,6 +35,8 @@ v3 has one rule at the top level and on the wrappers: the underscore belongs to A directive out of its place is refused like any unknown key: `{name: bug, _remove: true}` drops an entry, while a top-level `_remove: true` or `labels: {_remove: true, entries: []}` fails validation. A removal also needs a fold to act in: in a single document (a one-file apply or check) it fails validation naming its site, since there is no lower layer to remove from. +Two cases draw a warning instead, in a document that otherwise validates, and the run goes on until the next major refuses them. A `_remove` on an entry of a list that is not keyed (a ruleset's `bypass_actors`) removes nothing and passes through as a field. A single document's top-level `_layering` outside its three values is read by nothing. + ```yaml settings # owner: platform-team, see runbook RB-112 labels: diff --git a/test/engine/diff.test.ts b/test/engine/diff.test.ts index f3c26e10..48b59cf6 100644 --- a/test/engine/diff.test.ts +++ b/test/engine/diff.test.ts @@ -286,7 +286,8 @@ describe("replace-style writes", () => { { kind: "omitted", path: ["enforcement"], live: "active", nullable: false }, ]); // GitHub fills defaults inside a passthrough path, so the sweep skips it, an omitted passthrough key included, - // while the typed list key holding passthrough items still counts. + // while the typed list key holding passthrough items still counts. The paths are this test's own: a ruleset's + // sweep stops one level up, at `rules[]` (replaceSweep in snapshot-helpers.ts). const passthrough = ["rules[].parameters", "bypass_actors[]"]; expect(deltas(desired, live, { matchBy, replace: { passthrough, nullable: [] } })).toEqual([ { diff --git a/test/sections/repository/schema.test.ts b/test/sections/repository/schema.test.ts index dbd66236..a083b64d 100644 --- a/test/sections/repository/schema.test.ts +++ b/test/sections/repository/schema.test.ts @@ -19,7 +19,7 @@ function issues(repository: Record): readonly string[] | null { const SQUASH_PAIRS = ". Legal pairs: PR_TITLE with PR_BODY or BLANK or COMMIT_MESSAGES; COMMIT_OR_PR_TITLE with COMMIT_MESSAGES"; -describe("a repository setting GitHub would 422 or never converge on is refused at parse, naming the key and the fix", () => { +describe("each repository setting listed here, which GitHub would 422 or never converge on, is refused at parse, naming the key and the fix", () => { test.each<[what: string, repository: Record, expected: string[]]>([ [ "a YAML-quoted toggle",