From 8b9cbe885e24f687a1faa3457509a65c72b502ca Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:29:00 -0500 Subject: [PATCH 1/2] docs: correct the sentences this week's fixes left behind Sixteen sentences, on the pages and in two test files, said what the code no longer does, or said it too widely. Each gets the smallest edit that makes it true, checked against the code it describes. Nothing under src/ changes. - The fold: a label's name under the rename rule is the one value the higher layer does not win (architecture, the v2-to-v3 layering page). - The canonical order: the pinned environments keep their written order too (library, the v2-to-v3 index). - Check and apply: the drift report cannot list the value of a declared secret that already exists. A check right after an apply reports clean where that apply reached the declared state, with three states no apply reaches as examples. A refused entry stops its section, which lists what it left as not attempted (README, semantics). - Keys GitHub only reports: three passthrough entry shapes name one in a note until the next major refuses it, and the two variables sections say so (semantics, actions_variables, agents_variables, and the regenerated coverage page). - Directives: a _remove on an entry of a list that is not keyed stays as a field under a warning, and a single document's top-level _layering outside its values warns (layering, architecture, the settings-file upgrade page, the _layering hover). - The replace sweep stops inside a ruleset's rule, not at its parameters (the run upgrade page, a test comment). - The snapshot page names the interaction_limits keys read on their own, the --summary row tells a created file from an existing one, and CONTRIBUTING says build:check also refuses an unregistered GENERATED marker. - One list item and one hover paragraph come under the 70-word cap, and a describe title claims only its own rows. --- CONTRIBUTING.md | 2 +- README.md | 4 ++- docs/operate/layering.md | 4 +-- docs/operate/snapshot.md | 8 ++--- docs/reference/architecture.md | 6 ++-- docs/reference/coverage.md | 2 ++ docs/reference/library.md | 38 ++++++++++++------------ docs/reference/semantics.md | 8 +++-- docs/schema.docs.yml | 13 +++++--- docs/sections/actions_variables.docs.yml | 3 ++ docs/sections/agents_variables.docs.yml | 3 ++ docs/start/cli.md | 12 ++++---- docs/upgrading/v2-to-v3.md | 16 +++++----- docs/upgrading/v2-to-v3/layering.md | 2 +- docs/upgrading/v2-to-v3/run.md | 2 +- docs/upgrading/v2-to-v3/settings-file.md | 2 ++ test/engine/diff.test.ts | 3 +- test/sections/repository/schema.test.ts | 2 +- 18 files changed, 77 insertions(+), 53 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acacbea7d..efe80fa3e 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 26fd03ab4..a87e2cdf2 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 cd52ba7fa..85d62c55c 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 572d83eca..04c9dd61e 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -194,10 +194,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 6cca5e561..db1a0109c 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 39406bde8..7c0b1b52b 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 c1eb01c1a..647c5fab4 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 40ccc2a4d..43790c26a 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 apply reaches, because its section has no write for it: 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 b23090c68..36591bf54 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 e0beaaf2a..8fce999f0 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 4af0eb404..48f76eb71 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 6afe1dcc2..25e5c1fb5 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 54c2790be..ce1b59c3e 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 d646e7344..f927d411d 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 9c64135a6..321f7cb60 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 690893a45..87ad439d7 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 f3c26e103..48b59cf6e 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 dbd66236f..a083b64d3 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", From 28f0db744b8d2951df26c1b687a03715c62ce535 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:13:57 -0500 Subject: [PATCH 2/2] docs(semantics): say no operation of the plan reaches the three example states The sentence gave the reason "because its section has no write for it". That is literal only for workflows: interaction_limits declares a cap PATCH, and pages declares a create and an update. What the three examples share is that the plan holds no operation that reaches the declared state, so the clause says that. --- docs/reference/semantics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/semantics.md b/docs/reference/semantics.md index 43790c26a..a96bcc282 100644 --- a/docs/reference/semantics.md +++ b/docs/reference/semantics.md @@ -22,7 +22,7 @@ In a ruleset the sweep stops inside a rule (its `parameters` included) and insid 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 apply reaches, because its section has no write for it: 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. +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