From 58f2958351262eb9f08aaa612e238e32eab59207 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:52:31 -0400 Subject: [PATCH 1/9] feat(snapshot): mode snapshot writes live settings to a file or a per-repo directory mode: snapshot reads the target repositories' live settings back through the section snapshot ports and writes each as a settings document: one repository to snapshot-file, or every multi-repo target (resolved exactly as apply resolves them, discovery filters included) to //.yml, the repos-dir layout. The document reaches only the file; annotations, the summary, and the outputs carry section keys, statuses, and notes through the same redaction channel check mode uses, so a private target's file lands on disk while the public surfaces know it as its placeholder. The result output reads snapshot, partial, or failed. The flow reuses the multi-repo target resolution, extracted from runMulti as resolveTargets, and the single-repo channel opener. The library gains snapshotRepository and snapshotRepositories, returning the engine result plus the rendered file. The inputs gain snapshot-file and snapshot-dir (exactly one required in snapshot mode, both rejected elsewhere) and an allowlist that rejects every apply-, check-, or merge-time input, mirroring the merge. The e2e harness gains mode: snapshot, the snapshot pins, and a round-trip re-run that checks the written file clean against the same seeded state; every section with a snapshot handler has one round-trip scenario, plus the unsupported-and-denied, denied-fails, and dir-form multi-repo scenarios. The snapshot guide documents the mode, the $NAME placeholders, the round trip and its exceptions, and the directory form. The schema hint a snapshot writes names the moving major tag like the README, so release-please-config.json lists src/flows/snapshot.ts; the layering guide's rejected-inputs row and the schema-corpus pin follow the new inputs and scenarios. --- README.md | 2 +- action.yml | 60 ++- docs/README.md | 2 + docs/operate/layering.md | 2 +- docs/operate/snapshot.md | 184 ++++++++ docs/reference/inputs.md | 8 +- docs/upgrading/README.md | 8 + release-please-config.json | 2 + src/action/io.ts | 4 +- src/action/run.ts | 8 + src/flows/inputs.ts | 300 ++++++++++--- src/flows/library.ts | 66 +++ src/flows/multi.ts | 75 +++- src/flows/single.ts | 2 +- src/flows/snapshot.ts | 409 ++++++++++++++++++ src/flows/summary.ts | 67 ++- src/index.ts | 32 +- src/problem.ts | 57 ++- .../actions_secrets-snapshot-roundtrip.yml | 37 ++ .../actions_variables-snapshot-roundtrip.yml | 30 ++ .../agents_secrets-snapshot-roundtrip.yml | 37 ++ .../agents_variables-snapshot-roundtrip.yml | 30 ++ .../autolinks-snapshot-roundtrip.yml | 29 ++ .../code_quality_setup-snapshot-roundtrip.yml | 24 + ...nning_default_setup-snapshot-roundtrip.yml | 38 ++ .../codespaces_secrets-snapshot-roundtrip.yml | 37 ++ .../custom_properties-snapshot-roundtrip.yml | 38 ++ .../dependabot_secrets-snapshot-roundtrip.yml | 37 ++ .../deploy_keys-snapshot-roundtrip.yml | 35 ++ .../interaction_limits-snapshot-roundtrip.yml | 36 ++ .../scenarios/labels-snapshot-roundtrip.yml | 35 ++ .../milestones-snapshot-roundtrip.yml | 39 ++ .../scenarios/pages-snapshot-roundtrip.yml | 47 ++ ...ing_custom_patterns-snapshot-roundtrip.yml | 40 ++ .../scenarios/webhooks-snapshot-roundtrip.yml | 55 +++ .../workflows-snapshot-roundtrip.yml | 38 ++ test/action/action-yml.test.ts | 7 +- test/action/run.test.ts | 92 ++++ test/docs/guides.test.ts | 24 +- test/docs/readme.test.ts | 5 + test/e2e/run.ts | 25 +- test/e2e/runner.ts | 114 ++++- test/e2e/scenarios/snapshot-denied-fails.yml | 35 ++ .../e2e/scenarios/snapshot-dir-multi-repo.yml | 77 ++++ .../snapshot-unsupported-and-denied.yml | 50 +++ test/e2e/schema.ts | 79 +++- test/flows/inputs.test.ts | 211 ++++++++- test/flows/library.test.ts | 96 +++- test/flows/snapshot.test.ts | 379 ++++++++++++++++ test/library/public-surface.test.ts | 20 + test/problem.test.ts | 46 ++ test/schema-corpus.test.ts | 11 +- 52 files changed, 3086 insertions(+), 135 deletions(-) create mode 100644 docs/operate/snapshot.md create mode 100644 src/flows/snapshot.ts create mode 100644 src/sections/actions_secrets/scenarios/actions_secrets-snapshot-roundtrip.yml create mode 100644 src/sections/actions_variables/scenarios/actions_variables-snapshot-roundtrip.yml create mode 100644 src/sections/agents_secrets/scenarios/agents_secrets-snapshot-roundtrip.yml create mode 100644 src/sections/agents_variables/scenarios/agents_variables-snapshot-roundtrip.yml create mode 100644 src/sections/autolinks/scenarios/autolinks-snapshot-roundtrip.yml create mode 100644 src/sections/code_quality_setup/scenarios/code_quality_setup-snapshot-roundtrip.yml create mode 100644 src/sections/code_scanning_default_setup/scenarios/code_scanning_default_setup-snapshot-roundtrip.yml create mode 100644 src/sections/codespaces_secrets/scenarios/codespaces_secrets-snapshot-roundtrip.yml create mode 100644 src/sections/custom_properties/scenarios/custom_properties-snapshot-roundtrip.yml create mode 100644 src/sections/dependabot_secrets/scenarios/dependabot_secrets-snapshot-roundtrip.yml create mode 100644 src/sections/deploy_keys/scenarios/deploy_keys-snapshot-roundtrip.yml create mode 100644 src/sections/interaction_limits/scenarios/interaction_limits-snapshot-roundtrip.yml create mode 100644 src/sections/labels/scenarios/labels-snapshot-roundtrip.yml create mode 100644 src/sections/milestones/scenarios/milestones-snapshot-roundtrip.yml create mode 100644 src/sections/pages/scenarios/pages-snapshot-roundtrip.yml create mode 100644 src/sections/secret_scanning_custom_patterns/scenarios/secret_scanning_custom_patterns-snapshot-roundtrip.yml create mode 100644 src/sections/webhooks/scenarios/webhooks-snapshot-roundtrip.yml create mode 100644 src/sections/workflows/scenarios/workflows-snapshot-roundtrip.yml create mode 100644 test/e2e/scenarios/snapshot-denied-fails.yml create mode 100644 test/e2e/scenarios/snapshot-dir-multi-repo.yml create mode 100644 test/e2e/scenarios/snapshot-unsupported-and-denied.yml create mode 100644 test/flows/snapshot.test.ts diff --git a/README.md b/README.md index 4b01a4c90..1086872e6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state 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. The [getting started guide](docs/start/getting-started.md) explains the drift output. + - Drop the `mode: check` line once the report says what you expect. The [getting started guide](docs/start/getting-started.md) explains the drift output; [Snapshot mode](docs/operate/snapshot.md) writes an existing repository's live settings as the file to start from. ```yaml # .github/workflows/settings.yml diff --git a/action.yml b/action.yml index be8da34b8..4610eac7b 100644 --- a/action.yml +++ b/action.yml @@ -33,13 +33,17 @@ inputs: default: ".github/settings.yml" mode: description: >- - apply (mutate), check (report drift, exit 1 on any), or merge (fold the + apply (mutate), check (report drift, exit 1 on any), merge (fold the settings-file layers into one document written to merged-file, with no token and no GitHub API call; merge reads only settings-file, merged-file, and layering, ignores token, and rejects every other input - set to a non-default value, since each controls an apply or check run). - check makes no settings changes, though a private report may still be - delivered. + set to a non-default value, since each controls an apply or check run), + or snapshot (read the live settings of the target repositories back and + write each as a settings document to snapshot-file or under + snapshot-dir; nothing is written to GitHub, the document reaches only + the file, and every input that controls an apply, a check, or a merge is + rejected). check makes no settings changes, though a private report may + still be delivered. required: false default: "apply" merged-file: @@ -55,6 +59,34 @@ inputs: apply or check. required: false default: "" + snapshot-file: + description: >- + mode: snapshot only, and exactly one of snapshot-file and snapshot-dir + is required there: the path one repository's live settings are written + to as a settings document (parent directories are created). The target + is the repository input, defaulting to the current repository, so it + cannot be combined with repos or repos-dir. The header pins the schema, + names the repository and the moment, and lists every section note; + secret values GitHub never reveals become $NAME references to export + before an apply. Must not be .github/settings.yml, the file apply and + check read: the snapshot would overwrite the document you author, so + write it beside that file and copy it over deliberately. Fails when set + in apply, check, or merge. + required: false + default: "" + snapshot-dir: + description: >- + mode: snapshot only, and exactly one of snapshot-file and snapshot-dir + is required there: the directory the multi-repo targets' live settings + are written under, one /.yml per target (the repos-dir + layout, so the directory can later serve as a repos-dir). The targets + come from repos and repos-dir exactly as in a multi-repo apply, + discovery filters included; defaults-file does not apply. Must be + disjoint from the repos-dir (not the same directory, not above it, not + below it): the snapshots would overwrite the central files or be read + back as central files. Fails when set in apply, check, or merge. + required: false + default: "" on-missing-permission: description: >- fail (default) or warn. Under warn, sections the token cannot access are @@ -72,10 +104,10 @@ inputs: default: "" sections: description: >- - Optional comma-separated allowlist of sections to process. apply and - check only: mode: merge writes every section its layers declare, so the - allowlist belongs on the step that runs the merged document and fails - the merge when set. + Optional comma-separated allowlist of sections to process. apply, check, + and snapshot only: mode: merge writes every section its layers declare, + so the allowlist belongs on the step that runs the merged document and + fails the merge when set. required: false default: "" api-version: @@ -219,18 +251,18 @@ outputs: result: description: >- applied | partial | clean | drift | failed (worst-of across all targets - in multi-repo mode, where skipped can also appear), or merged in mode: - merge. + in multi-repo mode, where skipped can also appear), merged in mode: + merge, or snapshot | partial | failed in mode: snapshot. skipped-sections: description: >- Comma-separated sections skipped for missing permissions (deduped union across targets in multi-repo mode). repos-result: description: >- - Multi-repo mode only: JSON map of owner/name to {result, source, - skippedSections}. A redacted private target is keyed by its "private - repository #N" placeholder instead of its slug. Empty in single-repo - mode. + Multi-repo mode (and the snapshot-dir form of mode: snapshot) only: JSON + map of owner/name to {result, source, skippedSections}. A redacted + private target is keyed by its "private repository #N" placeholder + instead of its slug. Empty in single-repo mode. # END GENERATED: action-outputs runs: diff --git a/docs/README.md b/docs/README.md index 82b9033d2..c3092b4ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the | Use the engine from your own code | [reference/library.md](reference/library.md) | | Run check, apply, or validate from a terminal | [start/cli.md](start/cli.md) | | Detect drift on a schedule | [operate/check-mode.md](operate/check-mode.md) | +| Write a repository's live settings to a file | [operate/snapshot.md](operate/snapshot.md) | | Fold several settings files into one | [operate/layering.md](operate/layering.md) | | Manage a fleet from one repository | [operate/multi-repo.md](operate/multi-repo.md) | | Keep private targets out of public logs | [operate/private-repositories.md](operate/private-repositories.md) | @@ -50,6 +51,7 @@ The documentation for GitHub Settings as Code, in five groups. Start here if the ## operate: day-to-day operation - [Check mode](operate/check-mode.md): drift detection on a schedule, exit codes, and what a "cannot verify" note is telling you. +- [Snapshot mode](operate/snapshot.md): `mode: snapshot` writes the live settings as a settings file, the `$NAME` placeholders secrets become, the round trip and its exceptions, and the per-repo directory form. - [Layering settings files](operate/layering.md): `mode: merge` folds an ordered list of files into one document, the rules of the fold, the `_layering` directive, and the two-step workflow. - [Multi-repo mode](operate/multi-repo.md): manage a fleet from one admin repository with per-repo files, discovery, and a defaults-file fallback for repositories without a file. - [Private repositories](operate/private-repositories.md): the redaction that keeps private targets out of public logs, and the private-report channels. diff --git a/docs/operate/layering.md b/docs/operate/layering.md index d2d8c9d8a..993c3eb56 100644 --- a/docs/operate/layering.md +++ b/docs/operate/layering.md @@ -58,7 +58,7 @@ Mappings merged key by key, the two label lists unioned by name, and `labels` ca | `merged-file` | Required: where the merged document is written (parent directories are created). It must not name one of the `settings-file` layers, compared as resolved paths: the run refuses that, naming the layer's position, because the merge would overwrite the layer and the next run would fold the merged document as one | | `layering` | `merge` (default) or `replace`: the run-wide default for the keyed list sections, see below | | `token` | Ignored: a merge makes no GitHub API call, so a token a workflow sets on every step does no harm | -| `repository`, `repos`, `repos-dir`, `defaults-file`, `visibility`, `archived`, `forks`, `exclude`, `topics`, `affiliation`, `sections`, `required-sections`, `on-missing-permission`, `api-version`, `private-repos`, `private-report`, `report-public-key` | Rejected when set to a non-default value: a merge addresses no repository, fleet, or report, calls no API, and writes every section its layers declare; a `sections` allowlist belongs on the step that runs the merged document | +| `repository`, `repos`, `repos-dir`, `defaults-file`, `snapshot-file`, `snapshot-dir`, `visibility`, `archived`, `forks`, `exclude`, `topics`, `affiliation`, `sections`, `required-sections`, `on-missing-permission`, `api-version`, `private-repos`, `private-report`, `report-public-key` | Rejected when set to a non-default value: a merge addresses no repository, fleet, or report, calls no API, and writes every section its layers declare; a `sections` allowlist belongs on the step that runs the merged document | The step ends with `result: merged` and exit 0, or exit 1 with an error naming the layer that was refused. diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md new file mode 100644 index 000000000..b0f2f7d86 --- /dev/null +++ b/docs/operate/snapshot.md @@ -0,0 +1,184 @@ +--- +order: 215 +--- + +# Snapshot mode + +`mode: snapshot` reads a repository's live settings back and writes them as a settings file. Nothing is written to GitHub, and the document reaches only the file: the log, the step summary, and the outputs carry section names, statuses, and the notes a check run prints for the same repository (a secret's name, a webhook's URL), never the document and never a secret's value. + +Use it to start managing a repository from what it has today, to keep a backup before an apply, or to bring a fleet under `repos-dir` management one file per repository. + +## One repository + +```yaml +name: Snapshot settings +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + snapshot: + runs-on: ubuntu-latest + steps: + - uses: Vivswan/github-settings-as-code@v2 # x-release-please-major + with: + token: ${{ secrets.ADMIN_TOKEN }} + mode: snapshot + snapshot-file: settings.snapshot.yml + - uses: actions/upload-artifact@v4 + with: + name: settings-snapshot + path: settings.snapshot.yml +``` + +The token needs the same read grants a check run needs for the sections you want (see [Token permissions](../reference/permissions.md)). No checkout is needed: a snapshot reads nothing from the working tree. + +## What the file looks like + +```yaml settings +# yaml-language-server: $schema=https://raw.githubusercontent.com/Vivswan/github-settings-as-code/v2/lib/settings.schema.json # x-release-please-major +# Snapshot of octocat/hello-world taken 2026-09-11T06:17:00.000Z +# actions_secrets[DEPLOY_TOKEN]: value of DEPLOY_TOKEN is not readable; export it into the environment as SECRET_ACTIONS_DEPLOY_TOKEN before apply +# check_suite_preferences: GitHub exposes no read endpoint for this section, so there is nothing to snapshot; apply re-asserts the declared value on every run +# milestones: nothing exists on the repository, so the section is omitted +labels: + _undeclared: delete + entries: + - name: bug + color: d73a4a + description: Something is broken +actions_secrets: + _undeclared: keep + entries: + - name: DEPLOY_TOKEN + value: $SECRET_ACTIONS_DEPLOY_TOKEN +``` + +Reading it top to bottom: + +- The first line pins the published schema, so an editor validates and autocompletes the file. +- The second line names the repository and the moment the snapshot was taken. +- Then one comment line per note: a secret whose value GitHub never reveals, a section the snapshot cannot read back, a section with nothing live to declare. The same notes appear as annotations on the run, exactly as a check run would print them, so a note names a secret or a webhook by its name or URL and never by a value. +- The document follows in the same form `mode: merge` writes: every list section in its wrapper form with the section's default `_undeclared` policy spelled out (see [the undeclared policy](../reference/undeclared-policy.md)), so an apply from the file does exactly what the header says. + +## 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: + +| What GitHub hides | What the file holds | What to export | +|---|---|---| +| A repository Actions secret named `DEPLOY_TOKEN` | `value: $SECRET_ACTIONS_DEPLOY_TOKEN` | `SECRET_ACTIONS_DEPLOY_TOKEN` | +| The same name in another store (`dependabot_secrets`, `codespaces_secrets`, `agents_secrets`) | `$SECRET_DEPENDABOT_DEPLOY_TOKEN`, `$SECRET_CODESPACES_DEPLOY_TOKEN`, `$SECRET_AGENTS_DEPLOY_TOKEN` | One variable per store, so two stores holding the same name never share a value by accident | +| A webhook's `config.secret`, one per hook | `secret: $WEBHOOK_SECRET_601` | `WEBHOOK_SECRET_`, the hook's own id, so the reference survives a reordering | + +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. + +## Backup, then check + +The written file is a settings document, so a check run can read it back against the repository it came from. Snapshot before an apply, and the check proves the file matches what was live: + +```yaml +name: Backup then check +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + backup: + runs-on: ubuntu-latest + steps: + - uses: Vivswan/github-settings-as-code@v2 # x-release-please-major + with: + token: ${{ secrets.ADMIN_TOKEN }} + mode: snapshot + snapshot-file: backup/settings.yml + - uses: Vivswan/github-settings-as-code@v2 # x-release-please-major + env: + SECRET_ACTIONS_DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} + with: + token: ${{ secrets.ADMIN_TOKEN }} + mode: check + settings-file: backup/settings.yml + - uses: actions/upload-artifact@v4 + with: + name: settings-backup + path: backup/settings.yml +``` + +The check reads `clean`: the round trip holds for every section the snapshot reads back. Commit the file as your `.github/settings.yml` when it says what you expect, or keep the artifact as the record of what was live before a change. + +## The round trip and its exceptions + +A snapshot is written so that applying it changes nothing and checking it reads clean. The harness proves that for every supported section on every change (`test/sections/snapshot-roundtrip.test.ts` and the per-section `*-snapshot-roundtrip` scenarios). The exceptions are the values GitHub does not read back, each named in the file header: + +| Exception | Why | What the file holds | +|---|---|---| +| Secret values | GitHub returns names only | A `$NAME` reference and a note per secret | +| `webhooks[].config.secret` | GitHub echoes `********` | A `$WEBHOOK_SECRET_N` reference and a note per hook | +| `interaction_limits.expiry` | GitHub reports only the computed `expires_at` | No `expiry` key; apply re-arms the limit with GitHub's default unless you declare one | +| `check_suite_preferences` | GitHub exposes no read endpoint | Nothing; the header says so | +| Sections the snapshot does not read back yet | Not implemented for that section | Nothing; the header lists each, and the run notices them | + +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. + +## Many repositories + +`snapshot-dir` writes one file per target in the layout `repos-dir` reads, `//.yml`. The targets come from `repos` and `repos-dir` exactly as in a [multi-repo](multi-repo.md) apply, `repos: "*"` discovery and its filters included; `defaults-file` has no meaning here and is rejected. + +```yaml +name: Snapshot the fleet +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + snapshot: + runs-on: ubuntu-latest + steps: + - uses: Vivswan/github-settings-as-code@v2 # x-release-please-major + with: + token: ${{ secrets.FLEET_TOKEN }} + mode: snapshot + snapshot-dir: snapshots + repos: "*" + archived: skip + - uses: actions/upload-artifact@v4 + with: + name: fleet-snapshots + path: snapshots +``` + +Copy the directory in as your `repos-dir` to bring the fleet under management, one reviewable file per repository. The step summary lists every target with its result and file, then one section table per target, and the `repos-result` output carries the per-repository results as JSON. + +Private and internal targets are redacted by default, exactly as in a multi-repo apply (see [private repositories](private-repositories.md)): their file is written like the others, but the public surfaces know them only as `private repository #N`, with the file name and every detail hidden. Their notes are in their file header; there is no report channel in snapshot mode. + +## Inputs in mode: snapshot + +| Input | In `mode: snapshot` | +|---|---| +| `mode` | `snapshot` | +| `snapshot-file` | One repository: where its document is written (parent directories are created). Exactly one of `snapshot-file` and `snapshot-dir` is required. Must not be `.github/settings.yml`: the snapshot would overwrite the file you author, so write it beside that file and copy it over deliberately | +| `snapshot-dir` | Many repositories: the directory receiving one `/.yml` per `repos` or `repos-dir` target. Must be disjoint from the `repos-dir` (not the same directory, not above it, not below it): the snapshots would overwrite the central files or be read back as central files on the next run | +| `repository` | With `snapshot-file`: the target, defaulting to the current repository. Rejected with `snapshot-dir` | +| `repos`, `repos-dir`, `visibility`, `archived`, `forks`, `exclude`, `topics`, `affiliation` | With `snapshot-dir`: the targets and the discovery filters, as in multi-repo mode. Rejected with `snapshot-file` | +| `sections` | The allowlist of sections to read back; every other section is left out of the file | +| `on-missing-permission` | `fail` (default) fails the run on a section the token cannot read, and no file is written for that target; `warn` skips the section with a warning and writes the rest | +| `private-repos` | `redact` (default) hides private and internal targets from the public surfaces; `show` reveals them | +| `token`, `api-version` | The API calls, as in every mode that reaches GitHub | +| `settings-file`, `merged-file`, `layering`, `required-sections`, `defaults-file`, `private-report`, `report-public-key` | Rejected when set to a non-default value: a snapshot applies no document, folds no layers, and delivers no report | + +## Results and exit codes + +| `result` | Exit | Meaning | +|---|---|---| +| `snapshot` | 0 | Every target read fully back and its file was written | +| `partial` | 0 | A section was skipped under `on-missing-permission: warn`, or failed on its own; the file omits it, the header says why, and `skipped-sections` lists the skipped ones | +| `failed` | 1 | A target failed: a denial under `fail`, an unwritable path, or a value a section's own schema rejects (a bug, reported as such). No file is written for a failed target | + +In the `snapshot-dir` form the worst result across targets decides, and `repos-result` maps each target to its own. diff --git a/docs/reference/inputs.md b/docs/reference/inputs.md index 540d8b78e..eb9d3f009 100644 --- a/docs/reference/inputs.md +++ b/docs/reference/inputs.md @@ -14,11 +14,13 @@ Every `with:` input the action accepts, and the outputs it sets for the steps af | `token` | `github.token` | Token for the API calls (see [Token permissions](permissions.md)) | | `repository` | current repo | Target `owner/name` (single-repo mode only) | | `settings-file` | `.github/settings.yml` | Settings file path (single-repo mode); in `mode: merge`, the ordered list of layers to fold, low to high | -| `mode` | `apply` | `apply` mutates; `check` reports drift and exits 1 on any, making no settings changes (a private report may still be delivered); `merge` folds the settings-file layers into merged-file without touching GitHub | +| `mode` | `apply` | `apply` mutates; `check` reports drift and exits 1 on any, making no settings changes (a private report may still be delivered); `merge` folds the settings-file layers into merged-file without touching GitHub; `snapshot` writes the live settings to snapshot-file or snapshot-dir | | `merged-file` | (empty) | `mode: merge` only (required there): where the merged document is written, exactly what `apply` would run | +| `snapshot-file` | (empty) | `mode: snapshot` only (one of the two required there): where one repository's live settings are written as a settings document | +| `snapshot-dir` | (empty) | `mode: snapshot` only (one of the two required there): directory receiving one `/.yml` per multi-repo target | | `on-missing-permission` | `fail` | `warn` skips sections the token cannot access (partial success) | | `required-sections` | (empty) | Sections that must fully apply even under `warn` | -| `sections` | (all declared) | Comma-separated allowlist of sections to process (apply and check only; rejected in `mode: merge`) | +| `sections` | (all declared) | Comma-separated allowlist of sections to process (apply, check, and snapshot; rejected in `mode: merge`) | | `api-version` | `2022-11-28` | `X-GitHub-Api-Version` header; override to opt into a newer REST API version | | `repos` | (empty) | Multi-repo remote mode: `owner/name` list (comma/newline), or `*` to discover owned repos | | `repos-dir` | (empty) | Multi-repo central mode: directory of per-repo settings files in this repo | @@ -39,7 +41,7 @@ The discovery-only inputs apply to `repos: "*"`; the [multi-repo guide](../opera ## Outputs -- `result`: `applied` / `partial` / `clean` / `drift` / `failed`; worst-of across targets in multi-repo mode, where `skipped` can also appear; `merged` in mode: merge. +- `result`: `applied` / `partial` / `clean` / `drift` / `failed`; worst-of across targets in multi-repo mode, where `skipped` can also appear; `merged` in mode: merge. In `mode: snapshot` it reads `snapshot`, `partial`, or `failed`; the [snapshot guide](../operate/snapshot.md) has that table. - `skipped-sections`: the sections skipped for missing permissions under `on-missing-permission: warn`, comma-separated (a deduped union across targets in multi-repo mode). - `repos-result`: multi-repo mode only, a JSON map of `owner/name` to `{result, source, skippedSections}`. A redacted private target is keyed by its `private repository #N` placeholder instead of its slug; see [Private repositories](../operate/private-repositories.md). diff --git a/docs/upgrading/README.md b/docs/upgrading/README.md index 31cf09add..57caf99fd 100644 --- a/docs/upgrading/README.md +++ b/docs/upgrading/README.md @@ -23,6 +23,14 @@ Every version tag cut since the `build` branch exists points at a packaged commi | v1 | v2 | [v1 to v2](v1-to-v2.md): the repository rename in `uses:`, and four keys that went from inert to acting | | v2 | v3 | [v2 to v3](v2-to-v3.md): `defaults-file` becomes a fallback, `undeclared` becomes `_undeclared`, and layering moves to `mode: merge` | +## Additions inside a major + +A minor release can add a mode, an input, or a `result` value without breaking a working file or workflow. Two additions worth knowing when a step branches on `result`: + +| Since | Addition | What to check | +|---|---|---| +| v3 | `mode: snapshot` ([guide](../operate/snapshot.md)) | `result` may read `snapshot` in that mode, beside `partial` and `failed`; a step that treats every unknown `result` as a failure should list it | + ## The convention Every future major adds a page to this folder and a row to the table above before it ships. The [CHANGELOG](https://github.com/Vivswan/github-settings-as-code/blob/main/CHANGELOG.md) keeps the release-please footers; the guide is the reading order, with a row per break and a check-first step where a break is silent. diff --git a/release-please-config.json b/release-please-config.json index ae627ded2..bf25bb1fe 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -14,6 +14,7 @@ "README.md", "docs/start/getting-started.md", "docs/operate/check-mode.md", + "docs/operate/snapshot.md", "docs/operate/multi-repo.md", "docs/operate/layering.md", "docs/start/migrating-from-probot.md", @@ -25,6 +26,7 @@ "docs/reference/secrets-and-vaults.md", "docs/upgrading/README.md", ".github/ISSUE_TEMPLATE/bug_report.yml", + "src/flows/snapshot.ts", { "type": "json", "path": "package.json", diff --git a/src/action/io.ts b/src/action/io.ts index 7cc1886fe..9f2f2d91a 100644 --- a/src/action/io.ts +++ b/src/action/io.ts @@ -6,7 +6,7 @@ import { type Io, maskRegistry, type OutputName } from "../index.js"; export const OUTPUT_DECLS = { result: { description: - "applied | partial | clean | drift | failed (worst-of across all targets in multi-repo mode, where skipped can also appear), or merged in mode: merge.", + "applied | partial | clean | drift | failed (worst-of across all targets in multi-repo mode, where skipped can also appear), merged in mode: merge, or snapshot | partial | failed in mode: snapshot.", }, "skipped-sections": { description: @@ -14,7 +14,7 @@ export const OUTPUT_DECLS = { }, "repos-result": { description: - 'Multi-repo mode only: JSON map of owner/name to {result, source, skippedSections}. A redacted private target is keyed by its "private repository #N" placeholder instead of its slug. Empty in single-repo mode.', + 'Multi-repo mode (and the snapshot-dir form of mode: snapshot) only: JSON map of owner/name to {result, source, skippedSections}. A redacted private target is keyed by its "private repository #N" placeholder instead of its slug. Empty in single-repo mode.', }, } as const satisfies Record; diff --git a/src/action/run.ts b/src/action/run.ts index bdf4cf5e4..028d4b5d2 100644 --- a/src/action/run.ts +++ b/src/action/run.ts @@ -2,6 +2,7 @@ import { type ArtifactUploader, concludeMerge, concludeRun, + concludeSnapshot, failRun, GithubApi, type GithubClient, @@ -9,6 +10,7 @@ import { runMerge, runMulti, runSingle, + runSnapshot, } from "../index.js"; import { actionsArtifactUploader } from "./artifact.js"; import { parseActionConfig } from "./inputs.js"; @@ -37,6 +39,12 @@ export async function run(overrides?: { } const api = overrides?.api ?? new GithubApi({ token: cfg.token, io, apiVersion: cfg.apiVersion }); + if (cfg.kind === "snapshot") { + return runSnapshot(api, cfg, io).match( + (finished) => concludeSnapshot(io, finished), + (problem) => failRun(io, problem), + ); + } if (cfg.kind === "multi") { return runMulti(api, cfg, io, uploader).match( (targets) => concludeRun(io, { kind: "multi", mode: cfg.mode, targets }), diff --git a/src/flows/inputs.ts b/src/flows/inputs.ts index 0e737b48e..97a9c9934 100644 --- a/src/flows/inputs.ts +++ b/src/flows/inputs.ts @@ -13,7 +13,7 @@ import { FORKS_FILTERS, VISIBILITY_FILTERS, } from "../discovery/discover.js"; -import { parseRepoSlug } from "../discovery/targets.js"; +import { parseRepoSlug, type RepoRef } from "../discovery/targets.js"; import type { Layering } from "../engine/layers.js"; import { SectionSelection } from "../engine/section-selection.js"; import { DEFAULT_API_VERSION } from "../github/api.js"; @@ -27,6 +27,7 @@ import type { MergeConfig } from "./merge.js"; import { DEFAULT_SETTINGS_FILE, type MultiConfig } from "./multi.js"; import { PRIVATE_REPOS_POLICIES, type PrivateReposPolicy } from "./redact.js"; import type { SingleConfig } from "./single.js"; +import type { SnapshotConfig } from "./snapshot.js"; /** Default `private-repos`, pinned against action.yml by the contract test. */ export const DEFAULT_PRIVATE_REPOS = "redact" satisfies PrivateReposPolicy; @@ -93,14 +94,20 @@ export const INPUT_DECLS = { }, mode: { description: - "apply (mutate), check (report drift, exit 1 on any), or merge (fold the settings-file " + - "layers into one document written to merged-file, with no token and no GitHub API call; " + - "merge reads only settings-file, merged-file, and layering, ignores token, and rejects every " + - "other input set to a non-default value, since each controls an apply or check run). check " + + "apply (mutate), check (report drift, exit 1 on any), merge (fold the settings-file layers " + + "into one document written to merged-file, with no token and no GitHub API call; merge reads " + + "only settings-file, merged-file, and layering, ignores token, and rejects every other input " + + "set to a non-default value, since each controls an apply or check run), or snapshot (read " + + "the live settings of the target repositories back and write each as a settings document to " + + "snapshot-file or under snapshot-dir; nothing is written to GitHub, the document reaches only " + + "the file, and every input that controls an apply, a check, or a merge is rejected). check " + "makes no settings changes, though a private report may still be delivered.", default: "apply", summary: - "`apply` mutates; `check` reports drift and exits 1 on any, making no settings changes (a private report may still be delivered); `merge` folds the settings-file layers into merged-file without touching GitHub", + "`apply` mutates; `check` reports drift and exits 1 on any, making no settings changes (a " + + "private report may still be delivered); `merge` folds the settings-file layers into " + + "merged-file without touching GitHub; `snapshot` writes the live settings to snapshot-file " + + "or snapshot-dir", }, "merged-file": { description: @@ -115,6 +122,35 @@ export const INPUT_DECLS = { summary: "`mode: merge` only (required there): where the merged document is written, exactly what `apply` would run", }, + "snapshot-file": { + description: + "mode: snapshot only, and exactly one of snapshot-file and snapshot-dir is required there: " + + "the path one repository's live settings are written to as a settings document (parent " + + "directories are created). The target is the repository input, defaulting to the current " + + "repository, so it cannot be combined with repos or repos-dir. The header pins the schema, " + + "names the repository and the moment, and lists every section note; secret values GitHub " + + "never reveals become $NAME references to export before an apply. Must not be " + + ".github/settings.yml, the file apply and check read: the snapshot would overwrite the " + + "document you author, so write it beside that file and copy it over deliberately. Fails " + + "when set in apply, check, or merge.", + default: "", + summary: + "`mode: snapshot` only (one of the two required there): where one repository's live settings are written as a settings document", + }, + "snapshot-dir": { + description: + "mode: snapshot only, and exactly one of snapshot-file and snapshot-dir is required there: " + + "the directory the multi-repo targets' live settings are written under, one " + + "/.yml per target (the repos-dir layout, so the directory can later serve as a " + + "repos-dir). The targets come from repos and repos-dir exactly as in a multi-repo apply, " + + "discovery filters included; defaults-file does not apply. Must be disjoint from the " + + "repos-dir (not the same directory, not above it, not below it): the snapshots would " + + "overwrite the central files or be read back as central files. Fails when set in apply, " + + "check, or merge.", + default: "", + summary: + "`mode: snapshot` only (one of the two required there): directory receiving one `/.yml` per multi-repo target", + }, "on-missing-permission": { description: "fail (default) or warn. Under warn, sections the token cannot access are skipped with a warning and the run stays green (partial success).", @@ -133,10 +169,10 @@ export const INPUT_DECLS = { }, sections: { description: - "Optional comma-separated allowlist of sections to process. apply and check only: mode: merge writes every section its layers declare, so the allowlist belongs on the step that runs the merged document and fails the merge when set.", + "Optional comma-separated allowlist of sections to process. apply, check, and snapshot only: mode: merge writes every section its layers declare, so the allowlist belongs on the step that runs the merged document and fails the merge when set.", default: "", summary: - "Comma-separated allowlist of sections to process (apply and check only; rejected in `mode: merge`)", + "Comma-separated allowlist of sections to process (apply, check, and snapshot; rejected in `mode: merge`)", shownDefault: "(all declared)", list: true, }, @@ -358,7 +394,7 @@ function splitList(value: string): string[] { .filter(Boolean); } -export const MODES = ["apply", "check", "merge"] as const; +export const MODES = ["apply", "check", "merge", "snapshot"] as const; export type Mode = (typeof MODES)[number]; @@ -370,6 +406,10 @@ type _UnlistedLayering = MustBeNever { const sectionInputs = ["required-sections", "sections"] as const; @@ -420,7 +460,8 @@ interface CommonConfig extends RunFlowConfig { export type RunConfig = | (CommonConfig & (({ kind: "single" } & SingleConfig) | ({ kind: "multi" } & MultiConfig))) - | ({ kind: "merge" } & MergeConfig); + | ({ kind: "merge" } & MergeConfig) + | ({ kind: "snapshot" } & Pick & SnapshotConfig); /** * `token` is tolerated unread (a workflow commonly sets it on every step). Every declared input NOT listed here is an @@ -468,23 +509,22 @@ function parseMergeConfig(input: Inputs): Result { - const input = inputs(read); +/** The token the API modes call with: the input, else the environment's. */ +function readToken(input: Inputs, env: ConfigEnv): Result { + const token = input.value("token") || env.GITHUB_TOKEN || ""; + return token ? ok(token) : err({ code: "input-token-missing" }); +} + +/** The policies every API mode reads, each validated against its own vocabulary. */ +function readPolicies(input: Inputs): Result< + { + onMissingPermission: "fail" | "warn"; + sections: SectionSelection; + privateRepos: PrivateReposPolicy; + }, + Problem +> { return safeTry(function* () { - // The mode decides which inputs exist at all, so it is read first: a merge never needs the token. - const mode = yield* readEnum(input, "mode", MODES, INPUT_DECLS.mode.default, "mode"); - if (mode === "merge") { - return parseMergeConfig(input); - } - const mergeOnly = MERGE_ONLY_INPUTS.filter((name) => input.value(name) !== ""); - if (mergeOnly.length > 0) { - return err({ code: "input-merge-only", inputs: mergeOnly, mode }); - } - const token = input.value("token") || env.GITHUB_TOKEN || ""; - if (!token) { - return err({ code: "input-token-missing" }); - } - const githubRepository = env.GITHUB_REPOSITORY ?? ""; const onMissingPermission = yield* readEnum( input, "on-missing-permission", @@ -493,7 +533,6 @@ export function parseConfig(read: InputReader, env: ConfigEnv): Result { + return safeTry(function* () { const discoveryFiltersSet = FILTER_INPUTS.filter((name) => input.value(name) !== ""); const visibility = yield* readEnum( input, @@ -581,6 +598,170 @@ export function parseConfig(read: InputReader, env: ConfigEnv): Result topic.toLowerCase()), exclude, }; + return ok({ discoveryFilters, discoveryFiltersSet }); + }); +} + +/** The single-repo target: the repository input, else the workflow's own repository. */ +function readSingleTarget(input: Inputs, githubRepository: string): Result { + const rawRepo = input.value("repository") || githubRepository; + return parseRepoSlug(rawRepo).mapErr( + (): Problem => ({ code: "input-repository-not-slug", value: rawRepo }), + ); +} + +/** + * Every declared input NOT listed here is an apply, check, or merge control, so the snapshot rejects it unless it + * holds its declared default, which the runner supplies whether or not the workflow set the input. + */ +export const SNAPSHOT_INPUTS = [ + "token", + "repository", + "mode", + "snapshot-file", + "snapshot-dir", + "on-missing-permission", + "sections", + "api-version", + "repos", + "repos-dir", + "private-repos", + ...FILTER_INPUTS, +] as const satisfies readonly InputName[]; + +/** + * Derived from the declarations, so a future input is rejected by the snapshot until listed in SNAPSHOT_INPUTS; + * exported so the snapshot guide's table is pinned to the whole set. + */ +export const SNAPSHOT_REJECTED_INPUTS: readonly InputName[] = ( + Object.keys(INPUT_DECLS) as InputName[] +).filter((name) => !(SNAPSHOT_INPUTS as readonly string[]).includes(name)); + +/** Read and validate the mode: snapshot inputs; the first problem wins. */ +function parseSnapshotConfig( + input: Inputs, + env: ConfigEnv, +): Result, Problem> { + return safeTry(function* () { + const rejected = SNAPSHOT_REJECTED_INPUTS.filter((name) => { + const value = input.value(name); + return value !== "" && value !== INPUT_DECLS[name].default; + }); + if (rejected.length > 0) { + return err({ code: "input-rejected-in-snapshot", inputs: rejected }); + } + const snapshotFile = input.value("snapshot-file"); + const snapshotDir = input.value("snapshot-dir"); + if (snapshotFile && snapshotDir) { + return err({ code: "input-snapshot-destinations-both" }); + } + if (!snapshotFile && !snapshotDir) { + return err({ code: "input-snapshot-destination-missing" }); + } + const token = yield* readToken(input, env); + const githubRepository = env.GITHUB_REPOSITORY ?? ""; + const policies = yield* readPolicies(input); + const filters = yield* readDiscoveryFilters(input); + const base = { + kind: "snapshot" as const, + token, + apiVersion: input.orDefault("api-version"), + onMissingPermission: policies.onMissingPermission, + sections: policies.sections, + privateRepos: policies.privateRepos, + selfSlug: githubRepository, + }; + const reposInput = input.value("repos"); + const reposDir = input.value("repos-dir"); + if (snapshotFile) { + if (reposInput || reposDir) { + return err({ code: "input-snapshot-file-with-multi" }); + } + if (filters.discoveryFiltersSet.length > 0) { + return err({ + code: "discovery-filters-without-wildcard", + filters: filters.discoveryFiltersSet, + targets: "snapshot-file", + }); + } + const repo = yield* readSingleTarget(input, githubRepository); + return ok({ ...base, form: "file" as const, repo, snapshotFile }); + } + if (input.value("repository")) { + return err({ code: "input-repository-with-snapshot-dir" }); + } + if (!reposInput && !reposDir) { + return err({ code: "input-snapshot-dir-without-targets" }); + } + return ok({ + ...base, + form: "dir" as const, + snapshotDir, + reposInput, + reposDir, + adminOwner: githubRepository.split("/")[0] ?? "", + discoveryFilters: filters.discoveryFilters, + discoveryFiltersSet: filters.discoveryFiltersSet, + }); + }); +} + +/** Read and validate every input through `read`; the first problem wins. */ +export function parseConfig(read: InputReader, env: ConfigEnv): Result { + const input = inputs(read); + return safeTry(function* () { + // The mode decides which inputs exist at all, so it is read first: a merge never needs the token. + const mode = yield* readEnum(input, "mode", MODES, INPUT_DECLS.mode.default, "mode"); + if (mode === "merge") { + return parseMergeConfig(input); + } + if (mode === "snapshot") { + return parseSnapshotConfig(input, env); + } + const mergeOnly = MERGE_ONLY_INPUTS.filter((name) => input.value(name) !== ""); + if (mergeOnly.length > 0) { + return err({ code: "input-merge-only", inputs: mergeOnly, mode }); + } + const snapshotOnly = SNAPSHOT_ONLY_INPUTS.filter((name) => input.value(name) !== ""); + if (snapshotOnly.length > 0) { + return err({ code: "input-snapshot-only", inputs: snapshotOnly, mode }); + } + const token = yield* readToken(input, env); + const githubRepository = env.GITHUB_REPOSITORY ?? ""; + const { onMissingPermission, sections, privateRepos } = yield* readPolicies(input); + const apiVersion = input.orDefault("api-version"); + const privateReport = yield* readEnum( + input, + "private-report", + PRIVATE_REPORT_CHANNELS, + INPUT_DECLS["private-report"].default, + "private-report channel", + ); + // A report channel only ever runs for a REDACTED target, so combined with private-repos: show it would silently deliver nothing. + if (privateReport !== "none" && privateRepos === "show") { + return err({ code: "input-report-without-redaction" }); + } + const reportPublicKey = yield* resolveReportPublicKey(input, privateReport); + const serverUrl = env.GITHUB_SERVER_URL ?? ""; + const runId = env.GITHUB_RUN_ID ?? ""; + const runUrl = + serverUrl && githubRepository && runId + ? `${serverUrl}/${githubRepository}/actions/runs/${runId}` + : ""; + const common: CommonConfig = { + token, + mode, + onMissingPermission, + sections, + apiVersion, + privateRepos, + privateReport, + reportPublicKey, + selfSlug: githubRepository, + runUrl, + }; + + const { discoveryFilters, discoveryFiltersSet } = yield* readDiscoveryFilters(input); const reposInput = input.value("repos"); const reposDir = input.value("repos-dir"); @@ -621,10 +802,7 @@ export function parseConfig(read: InputReader, env: ConfigEnv): Result ({ code: "input-repository-not-slug", value: rawRepo }), - ); + const repo = yield* readSingleTarget(input, githubRepository); return ok({ ...common, kind: "single", repo, settingsFile }); }); } diff --git a/src/flows/library.ts b/src/flows/library.ts index 054d30bbe..85a260843 100644 --- a/src/flows/library.ts +++ b/src/flows/library.ts @@ -1,5 +1,6 @@ import type { Result } from "neverthrow"; import { stringify as stringifyYaml } from "yaml"; +import type { RepoRef } from "../discovery/targets.js"; import { type RepoRunOptions, type RepoRunResult, @@ -7,10 +8,18 @@ import { type ValidatedSettings, validateSettingsDoc, } from "../engine/orchestrate.js"; +import { SectionSelection } from "../engine/section-selection.js"; +import { + type RenderableSnapshot, + snapshotRepository as readSnapshot, + renderSnapshotYaml, + type SnapshotResult, +} from "../engine/snapshot.js"; import type { GithubClient } from "../github/api.js"; import { type CollectedLine, collectingIo, type Io } from "../io.js"; import type { SettingsProblem } from "../problem.js"; import type { SectionKey } from "../schema.js"; +import { SNAPSHOT_SCHEMA_URL } from "./snapshot.js"; const UNNAMED_SOURCE = "the settings document"; @@ -62,3 +71,60 @@ export function applyRepository( export function renderMergedYaml(settings: ValidatedSettings): string { return stringifyYaml(settings); } + +/** What a library snapshot may narrow: the selection, the denial policy, and the Io the lines go to. */ +export interface SnapshotLibraryOptions { + sections?: SectionSelection; + onMissingPermission?: "fail" | "warn"; + io?: Io; +} + +/** + * The engine's snapshot result plus the file text mode: snapshot would write + * (absent exactly when the result is failed, which carries no document) and + * every line the run printed when the caller brought no Io of their own. + */ +export type SnapshotReport = ( + | (RenderableSnapshot & { yaml: string }) + | (Extract & { yaml?: never }) +) & { log: CollectedLine[] }; + +/** Read one repository's supported sections back as a settings document and its rendered file. */ +export async function snapshotRepository( + client: GithubClient, + repo: RepoRef, + options: SnapshotLibraryOptions = {}, +): Promise { + const collected = collectingIo(); + const result = await readSnapshot( + client, + { + repo, + sections: options.sections ?? SectionSelection.ALL, + onMissingPermission: options.onMissingPermission ?? "fail", + }, + options.io ?? collected.io, + ); + const log = options.io === undefined ? collected.lines : []; + if (result.result === "failed") { + return { ...result, log }; + } + const yaml = renderSnapshotYaml(result, { + schemaUrl: SNAPSHOT_SCHEMA_URL, + timestamp: new Date().toISOString(), + }); + return { ...result, yaml, log }; +} + +/** Snapshot several repositories in order, one report each; a failed target never stops the rest. */ +export async function snapshotRepositories( + client: GithubClient, + targets: readonly RepoRef[], + options: SnapshotLibraryOptions = {}, +): Promise { + const reports: SnapshotReport[] = []; + for (const repo of targets) { + reports.push(await snapshotRepository(client, repo, options)); + } + return reports; +} diff --git a/src/flows/multi.ts b/src/flows/multi.ts index cdf40d231..24c93773d 100644 --- a/src/flows/multi.ts +++ b/src/flows/multi.ts @@ -56,10 +56,14 @@ import { parseSettingsDoc, readSettingsFile } from "./settings-read.js"; /** The single source for the action.yml `settings-file` default, the multi-repo override guard in src/flows/inputs.ts, and the prose below. */ export const DEFAULT_SETTINGS_FILE = ".github/settings.yml"; -export interface MultiConfig extends RunFlowConfig { +/** + * Where a multi-repo run's targets come from and how their slugs may appear: + * the inputs resolveTargets reads. Apply, check, and snapshot share it, so + * the three modes name the same fleet from the same inputs. + */ +export interface TargetsConfig extends Pick { reposDir: string; reposInput: string; - defaultsFile: string; /** The owner a bare `.yml` file under repos-dir belongs to. */ adminOwner: string; discoveryFilters: DiscoveryFilters; @@ -67,6 +71,10 @@ export interface MultiConfig extends RunFlowConfig { discoveryFiltersSet: string[]; } +export interface MultiConfig extends RunFlowConfig, TargetsConfig { + defaultsFile: string; +} + /** The channel is the only sink in scope, so a redacted target's text lands only in its report. */ async function processTarget(ctx: { api: GithubClient; @@ -154,7 +162,7 @@ async function processTarget(ctx: { * Channel and exposure come from ONE redaction decision, so a redacted channel never travels with a shown exposure. * Discovery's full_name is API data, so parseRepoSlug is checked here; a slug that fails it becomes the target's failure. */ -function openTarget( +export function openTarget( plan: RedactionPlan, io: Io, slug: string, @@ -212,12 +220,27 @@ async function readTargetSettings( return { raw: file.content, sourceLabel, source: "target" }; } -export function runMulti( +/** The fleet a run acts on, with the redaction decision every target reports under. */ +export interface ResolvedTargets { + /** Deduped, central first, in the order the run processes them. */ + targets: Target[]; + plan: RedactionPlan; + /** A slug's resolved visibility; "unknown" for a slug never resolved. */ + visibilityOf: (slug: string) => RepoVisibility; +} + +/** + * Resolve the run's targets (repos-dir files, explicit repos, "*" discovery), + * decide redaction, and register every masked slug BEFORE the first line is + * emitted. A config problem (bad repos input, discovery failure, misplaced + * filters, no targets) comes back as the error; nothing has been emitted about + * a target when it does. + */ +export function resolveTargets( api: GithubClient, - cfg: MultiConfig, + cfg: TargetsConfig, io: Io, - uploader?: ArtifactUploader, -): ResultAsync { +): ResultAsync { // Central-resolution warnings are buffered so nothing emits before the redaction mask is registered; every exit path, // fatal or not, flushes them through this one helper (the fatal ones via orTee). They name repos-dir paths and slugs, // which are self-disclosed (checked into the public admin repo), so flushing them before masking on a fatal path leaks nothing. @@ -236,14 +259,6 @@ export function runMulti( const fail = (problem: Problem): Err => err(problem); return safeTry(async function* () { - yield* requireUploader(cfg, uploader); - - let defaults: ValidatedSettings | null = null; - if (cfg.defaultsFile) { - const doc = yield* readSettingsFile(cfg.defaultsFile, "defaults-file"); - defaults = yield* validateSettingsDoc(doc, cfg.defaultsFile, cfg.sections.only, io); - } - let central: CentralTarget[] = []; if (cfg.reposDir) { const resolved = yield* resolveCentralTargets(cfg.reposDir, cfg.adminOwner); @@ -357,6 +372,34 @@ export function runMulti( if (targets.length === 0) { return fail({ code: "no-targets", filteredOut: filteredOutCount }); } + return ok({ targets, plan, visibilityOf }); + }).orTee(flushWarnings); +} + +/** + * Multi-repo orchestration. Config-level problems (bad defaults file, no + * targets, duplicate definitions, discovery failure) come back as the error + * before any target executes; per-target problems mark that target failed or + * skipped and never stop the others. + */ +export function runMulti( + api: GithubClient, + cfg: MultiConfig, + io: Io, + uploader?: ArtifactUploader, +): ResultAsync { + return safeTry(async function* () { + yield* requireUploader(cfg, uploader); + + // The defaults document is read before the fleet is resolved: nothing about + // a target has been emitted yet, so its failure names only the local file. + let defaults: ValidatedSettings | null = null; + if (cfg.defaultsFile) { + const doc = yield* readSettingsFile(cfg.defaultsFile, "defaults-file"); + defaults = yield* validateSettingsDoc(doc, cfg.defaultsFile, cfg.sections.only, io); + } + + const { targets, plan, visibilityOf } = yield* resolveTargets(api, cfg, io); const results = await withDelivery({ api, cfg, io, uploader }, async (delivery) => { const delivered: TargetOutcome[] = []; @@ -383,5 +426,5 @@ export function runMulti( }); return ok(results); - }).orTee(flushWarnings); + }); } diff --git a/src/flows/single.ts b/src/flows/single.ts index a3fa47400..d59ba4b66 100644 --- a/src/flows/single.ts +++ b/src/flows/single.ts @@ -36,7 +36,7 @@ export interface SingleConfig extends RunFlowConfig { } /** Redaction fails closed: the target is hidden unless the probe proves it public (the self repository and the `show` policy skip the probe). */ -async function openSingleRepoChannel( +export async function openSingleRepoChannel( api: GithubClient, cfg: Pick, io: Io, diff --git a/src/flows/snapshot.ts b/src/flows/snapshot.ts new file mode 100644 index 000000000..8b736f565 --- /dev/null +++ b/src/flows/snapshot.ts @@ -0,0 +1,409 @@ +/** + * The mode: snapshot run flow: read each target's live settings back through + * the section snapshot ports and write them as a settings document, one file + * for one repository or one file per multi-repo target under a directory. + * The document reaches ONLY the file. Every public surface (annotations, the + * step summary, the outputs) carries section keys, statuses, and the notes + * check mode prints for the same repository (a secret's name, a webhook's + * URL, never a secret's value), routed through the target's redaction channel + * exactly as check mode routes its lines, so a redacted target's file lands + * on disk while nothing about it is printed. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { err, ok, type Result, ResultAsync } from "neverthrow"; +import type { RepoRef, Target } from "../discovery/targets.js"; +import type { SectionSelection } from "../engine/section-selection.js"; +import { renderSnapshotYaml, type SnapshotResult, snapshotRepository } from "../engine/snapshot.js"; +import type { GithubClient } from "../github/api.js"; +import type { Io } from "../io.js"; +import type { Problem } from "../problem.js"; +import type { SectionKey } from "../schema.js"; +import type { MustBeNever } from "../types.js"; +import type { Exposure } from "./deliver.js"; +import { + DEFAULT_SETTINGS_FILE, + openTarget, + type ResolvedTargets, + resolveTargets, + type TargetsConfig, +} from "./multi.js"; +import { + attempt, + type PrivateReposPolicy, + REDACTED_DETAIL, + REDACTED_NOTE, + type TargetChannel, +} from "./redact.js"; +import { openSingleRepoChannel } from "./single.js"; +import { writeSnapshotDirSummary, writeSnapshotSummary } from "./summary.js"; + +/** + * The `result` output of a mode: snapshot run, worst first: `failed` when a + * target failed (a denial under the fail policy, a value a section's own + * schema rejects, an unwritable file), `partial` when a section was skipped + * or failed without failing its target, `snapshot` when every target read + * fully back. Not RepoResult values: a snapshot applies nothing, so they + * never enter worstOf beside the per-repo values. + */ +export const SNAPSHOT_RESULTS = [ + "failed", + "partial", + "snapshot", +] as const satisfies readonly SnapshotResult["result"][]; + +export type SnapshotRunResult = (typeof SNAPSHOT_RESULTS)[number]; + +/** Compile-time lockstep: an engine result value missing from SNAPSHOT_RESULTS fails here. */ +type _UnlistedSnapshotResult = MustBeNever>; + +/** + * The schema the written file's editor hint points at: the schema of this + * release line, spelled as the README's quick start spells it. The marker + * lets a major release rewrite the tag here (release-please-config.json lists + * this file); test/docs/readme.test.ts pins it to the README's hint. + */ +export const SNAPSHOT_SCHEMA_URL = + "https://raw.githubusercontent.com/Vivswan/github-settings-as-code/v2/lib/settings.schema.json"; // x-release-please-major + +interface SnapshotConfigBase { + onMissingPermission: "fail" | "warn"; + /** The allowlist; its required set is unused, since a snapshot never writes. */ + sections: SectionSelection; + /** Whether to hide private/internal targets from the public view. */ + privateRepos: PrivateReposPolicy; + /** The repository the run acts for; a target equal to it is never redacted. */ + selfSlug: string; +} + +/** + * A mode: snapshot run: one repository written to `snapshotFile`, or the + * multi-repo targets (resolved exactly as apply resolves them) written under + * `snapshotDir` as `/.yml` each. No settings file, no report + * channel: a snapshot reads and writes a file, and the type carries that. + */ +export type SnapshotConfig = + | (SnapshotConfigBase & { form: "file"; repo: RepoRef; snapshotFile: string }) + | (SnapshotConfigBase & TargetsConfig & { form: "dir"; snapshotDir: string }); + +/** One snapshot target's end state before projection: the engine's outcomes plus where the file went. */ +interface SnapshotTargetResult { + result: SnapshotRunResult; + outcomes: SnapshotResult["outcomes"]; + /** Human line heading the target's summary. */ + note: string; + /** The written file, as the channel may name it; absent when nothing was written. */ + file?: string; +} + +/** One target as the summary and the outputs see it: closed values, detail already projected. */ +export interface SnapshotTargetView { + /** The public label: the slug, or its "private repository #N" placeholder. */ + display: string; + source?: Target["source"]; + result: SnapshotRunResult; + outcomes: Array<{ + key: SectionKey; + status: SnapshotResult["outcomes"][number]["status"]; + detail: string[]; + }>; + note: string; + /** Where the file went, as the public view may show it. */ + file?: string; +} + +/** A finished mode: snapshot run as runSnapshot hands it over: every target's public view. */ +export type FinishedSnapshot = + | { form: "file"; view: SnapshotTargetView } + | { form: "dir"; snapshotDir: string; views: SnapshotTargetView[] }; + +/** Whether `path` is `dir` itself or lies under it, compared as resolved paths. */ +function isWithin(path: string, dir: string): boolean { + const rel = relative(resolve(dir), resolve(path)); + // Only a whole ".." segment leaves `dir`: a child named "..snapshots" is inside. + const leaves = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + return !leaves; +} + +/** + * Refuse a destination that would overwrite an authored file. Guarded here, + * beside the write, so a library caller gets the refusal too. A snapshot is a + * starting point beside the file apply and check read, never a replacement + * written over it: that file carries the $NAME references and directives the + * operator authored. The dir form writes the repos-dir layout, so the two + * directories must be disjoint: the same directory overwrites every central + * file, a snapshot-dir above the repos-dir overwrites a bare .yml whose + * owner segment is the repos-dir's name, and one below it is read back as + * central files on the next run. Paths are compared resolved, so "./x" and "x" collide. + */ +function destinationCollision(cfg: SnapshotConfig): Result { + if (cfg.form === "file") { + return resolve(cfg.snapshotFile) === resolve(DEFAULT_SETTINGS_FILE) + ? err({ + code: "snapshot-file-is-settings-file", + snapshotFile: cfg.snapshotFile, + settingsFile: DEFAULT_SETTINGS_FILE, + }) + : ok(); + } + if ( + cfg.reposDir && + (isWithin(cfg.snapshotDir, cfg.reposDir) || isWithin(cfg.reposDir, cfg.snapshotDir)) + ) { + return err({ + code: "snapshot-dir-overlaps-repos-dir", + snapshotDir: cfg.snapshotDir, + reposDir: cfg.reposDir, + }); + } + return ok(); +} + +/** A target that produced no outcomes because `message` stopped it. */ +function failedSnapshotTarget(message: string): SnapshotTargetResult { + return { result: "failed", outcomes: [], note: message }; +} + +/** + * The public projection of one target: a redacted target keeps its section + * keys and statuses (closed values) and loses every detail line, its note, + * and its file path, which names the slug. + */ +function snapshotView( + channel: TargetChannel, + exposure: Exposure, + outcome: SnapshotTargetResult, + source?: Target["source"], +): SnapshotTargetView { + const redacted = exposure.kind === "redacted"; + return { + display: channel.display, + ...(source === undefined ? {} : { source }), + result: outcome.result, + outcomes: outcome.outcomes.map((o) => ({ + key: o.key, + status: o.status, + detail: redacted ? [REDACTED_DETAIL] : o.detail, + })), + note: redacted ? REDACTED_NOTE : outcome.note, + ...(outcome.file === undefined ? {} : { file: redacted ? REDACTED_DETAIL : outcome.file }), + }; +} + +/** + * Read one repository back and write its document to `path`, speaking only + * through the target's channel. The engine has already annotated every + * skipped and failed section; the unsupported ones get one notice here, since + * they are the sections the file will not carry. + */ +async function snapshotTarget(ctx: { + api: GithubClient; + repo: RepoRef; + cfg: SnapshotConfigBase; + path: string; + /** The input the path came from, named when the write fails. */ + pathInput: "snapshot-file" | "snapshot-dir"; + channel: TargetChannel; + timestamp: string; +}): Promise { + const { api, repo, cfg, path, channel } = ctx; + const result = await snapshotRepository( + api, + { repo, sections: cfg.sections, onMissingPermission: cfg.onMissingPermission }, + channel.io, + ); + const unsupported = result.outcomes.filter((o) => o.status === "unsupported").map((o) => o.key); + if (unsupported.length > 0) { + channel.io.annotate( + "notice", + `not snapshotted: ${unsupported.join(", ")} - snapshot does not read these sections back, so the file omits them (the header says why); declare them by hand if they should be managed`, + ); + } + if (result.result === "failed") { + return { + result: "failed", + outcomes: result.outcomes, + note: "the snapshot failed, so no file was written", + }; + } + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + renderSnapshotYaml(result, { schemaUrl: SNAPSHOT_SCHEMA_URL, timestamp: ctx.timestamp }), + ); + } catch (error) { + channel.io.annotate( + "error", + `cannot write the snapshot to ${path}: ${String(error)}. Check that the "${ctx.pathInput}" input names a writable path`, + ); + return { + result: "failed", + outcomes: result.outcomes, + note: `the snapshot could not be written to ${path}`, + }; + } + channel.io.log(`snapshot written to ${path}`); + return { + result: result.result, + outcomes: result.outcomes, + note: `written to ${path}`, + file: path, + }; +} + +/** + * A target's file under the snapshot directory, in the repos-dir layout so the + * directory can later serve as one. SLUG_RE admits "." and "..", which GitHub + * never issues but a repos entry can spell; either would leave the directory. + */ +function snapshotFilePath(dir: string, repo: RepoRef): { path: string } | { error: string } { + if ([repo.owner, repo.name].some((part) => part === "." || part === "..")) { + return { + error: `the repository name "${repo.slug}" is not a GitHub owner/name (a "." or ".." segment), so it has no file under ${dir}`, + }; + } + return { path: join(dir, repo.owner, `${repo.name}.yml`) }; +} + +/** + * Execute a mode: snapshot run. A destination that would overwrite an authored + * file, or a fleet that cannot be resolved, comes back as the error before any + * target is read; otherwise every target's public view, which concludeSnapshot + * turns into the summary, the outputs, and the exit code. + */ +export function runSnapshot( + api: GithubClient, + cfg: SnapshotConfig, + io: Io, +): ResultAsync { + return destinationCollision(cfg).asyncAndThen(() => + cfg.form === "file" + ? ResultAsync.fromSafePromise(snapshotFile(api, cfg, io)) + : resolveTargets(api, cfg, io).map((resolved) => snapshotDir(api, cfg, io, resolved)), + ); +} + +/** The file form: one target, opened as the single-repo flow opens its own. */ +async function snapshotFile( + api: GithubClient, + cfg: Extract, + io: Io, +): Promise { + const opened = await openSingleRepoChannel(api, cfg, io); + const outcome = await attempt( + opened.channel, + () => + snapshotTarget({ + api, + repo: cfg.repo, + cfg, + path: cfg.snapshotFile, + pathInput: "snapshot-file", + channel: opened.channel, + timestamp: new Date().toISOString(), + }), + failedSnapshotTarget, + ); + return { form: "file", view: snapshotView(opened.channel, opened.exposure, outcome) }; +} + +/** The dir form: every resolved target, each through the channel the redaction plan opens for it. */ +async function snapshotDir( + api: GithubClient, + cfg: Extract, + io: Io, + resolved: ResolvedTargets, +): Promise { + // One timestamp for the whole run, so every file's header shares it. + const timestamp = new Date().toISOString(); + const views: SnapshotTargetView[] = []; + for (const target of resolved.targets) { + // The channel is opened BEFORE any processing so a failure lands in a + // redacted target's capture too; it is the only sink processing sees. + const opened = openTarget(resolved.plan, io, target.slug, resolved.visibilityOf); + const { channel, repo } = opened; + const fail = (message: string): SnapshotTargetResult => { + channel.io.annotate("error", message); + return failedSnapshotTarget(message); + }; + let outcome: SnapshotTargetResult; + if (repo === null) { + outcome = fail( + `the repository name "${target.slug}" from ${target.origin} is not an owner/name slug, so it cannot be snapshotted`, + ); + } else { + const located = snapshotFilePath(cfg.snapshotDir, repo); + outcome = + "error" in located + ? fail(located.error) + : // A crash mid-target never stops the rest of the fleet; it becomes + // this target's failure, spoken only through its channel. + await attempt( + channel, + () => + snapshotTarget({ + api, + repo, + cfg, + path: located.path, + pathInput: "snapshot-dir", + channel, + timestamp, + }), + failedSnapshotTarget, + ); + } + views.push(snapshotView(channel, opened.exposure, outcome, target.source)); + } + return { form: "dir", snapshotDir: cfg.snapshotDir, views }; +} + +/** The worst result across every target; a run without targets never reaches here. */ +function worstSnapshotResult( + views: ReadonlyArray<{ result: SnapshotRunResult }>, +): SnapshotRunResult { + return SNAPSHOT_RESULTS.find((rank) => views.some((view) => view.result === rank)) ?? "snapshot"; +} + +/** A finished mode: snapshot run: the summary, the outputs, the result line, and the exit code. */ +export function concludeSnapshot(io: Io, finished: FinishedSnapshot): number { + const views = finished.form === "file" ? [finished.view] : finished.views; + if (finished.form === "file") { + writeSnapshotSummary(io, finished.view); + } else { + writeSnapshotDirSummary(io, finished.views, finished.snapshotDir); + io.output( + "repos-result", + JSON.stringify( + Object.fromEntries( + finished.views.map((view) => [ + view.display, + { + result: view.result, + source: view.source, + skippedSections: view.outcomes + .filter((o) => o.status === "skipped") + .map((o) => o.key), + }, + ]), + ), + ), + ); + } + io.output( + "skipped-sections", + [ + ...new Set( + views.flatMap((view) => + view.outcomes.filter((o) => o.status === "skipped").map((o) => o.key), + ), + ), + ].join(","), + ); + const result = worstSnapshotResult(views); + io.output("result", result); + io.log(`result: ${result}`); + return result === "failed" ? 1 : 0; +} diff --git a/src/flows/summary.ts b/src/flows/summary.ts index 695217bb8..446b5fc4a 100644 --- a/src/flows/summary.ts +++ b/src/flows/summary.ts @@ -1,21 +1,38 @@ import type { RepoResult, SectionOutcome } from "../engine/orchestrate.js"; +import type { SnapshotResult } from "../engine/snapshot.js"; import type { Io } from "../io.js"; import { markdownCell } from "../report/markdown.js"; import type { PublicDetail, PublicTargetView } from "./redact.js"; +import type { SnapshotTargetView } from "./snapshot.js"; type SummaryIo = Pick; -const STATUS_ICON: Record = { +const STATUS_ICON: Record< + | SectionOutcome["status"] + | RepoResult + | SnapshotResult["result"] + | SnapshotResult["outcomes"][number]["status"], + string +> = { applied: "white_check_mark", clean: "white_check_mark", + snapshot: "white_check_mark", drift: "warning", partial: "warning", skipped: "fast_forward", excluded: "fast_forward", + unsupported: "fast_forward", failed: "x", }; -function outcomeRows(outcomes: PublicDetail["outcomes"]): string[] { +/** A section row as every mode renders it: the key, a status the icon map knows, its detail lines. */ +interface SectionRow { + key: string; + status: keyof typeof STATUS_ICON; + detail: string[]; +} + +function outcomeRows(outcomes: readonly SectionRow[]): string[] { const rows = ["| Section | Status | Detail |", "|---|---|---|"]; for (const outcome of outcomes) { const detail = outcome.detail.map(markdownCell).join("
") || "-"; @@ -80,3 +97,49 @@ export function writeMultiSummary(io: SummaryIo, views: PublicTargetView[], mode } io.summary(lines.join("\n")); } + +/** The single-repo mode: snapshot summary: the result and where the file went, then the section table. */ +export function writeSnapshotSummary(io: SummaryIo, view: SnapshotTargetView): void { + const lines = [ + "## github-settings-as-code (snapshot)", + "", + `:${STATUS_ICON[view.result]}: ${view.result} - ${markdownCell(view.note)}`, + "", + ...outcomeRows(view.outcomes), + ]; + io.summary(lines.join("\n")); +} + +/** The snapshot-dir summary: the fleet rollup with each target's file, then one section table per target. */ +export function writeSnapshotDirSummary( + io: SummaryIo, + views: readonly SnapshotTargetView[], + snapshotDir: string, +): void { + const lines = [ + `## github-settings-as-code (snapshot, ${views.length} repositories)`, + "", + `Snapshots written under ${markdownCell(snapshotDir)}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + ]; + for (const view of views) { + lines.push( + `| ${markdownCell(view.display)} | ${view.source ?? "-"} | :${STATUS_ICON[view.result]}: ${view.result} | ${markdownCell(view.file ?? "-")} |`, + ); + } + for (const view of views) { + lines.push( + "", + `### ${markdownCell(view.display)} (${view.result})`, + "", + markdownCell(view.note), + "", + ); + if (view.outcomes.length > 0) { + lines.push(...outcomeRows(view.outcomes)); + } + } + io.summary(lines.join("\n")); +} diff --git a/src/index.ts b/src/index.ts index d47ef0ff8..a664f5310 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,7 +47,11 @@ export { worstOf, } from "./engine/orchestrate.js"; export { SectionSelection } from "./engine/section-selection.js"; - +export { + type RenderableSnapshot, + renderSnapshotYaml, + type SnapshotResult, +} from "./engine/snapshot.js"; export { concludeMerge, concludeRun, @@ -70,6 +74,8 @@ export { type Mode, parseConfig, type RunConfig, + SNAPSHOT_INPUTS, + SNAPSHOT_REJECTED_INPUTS, } from "./flows/inputs.js"; export { foldLayers, readLayerFiles } from "./flows/layers.js"; export { @@ -77,10 +83,21 @@ export { checkRepository, type RepoRunReport, renderMergedYaml, + type SnapshotLibraryOptions, + type SnapshotReport, + snapshotRepositories, + snapshotRepository, validateSettings, } from "./flows/library.js"; export { type MergeConfig, runMerge } from "./flows/merge.js"; -export { DEFAULT_SETTINGS_FILE, type MultiConfig, runMulti } from "./flows/multi.js"; +export { + DEFAULT_SETTINGS_FILE, + type MultiConfig, + type ResolvedTargets, + resolveTargets, + runMulti, + type TargetsConfig, +} from "./flows/multi.js"; export { capturingIo, PRIVATE_REPOS_POLICIES, @@ -93,7 +110,16 @@ export { } from "./flows/redact.js"; export { parseSettingsDoc, readSettingsFile } from "./flows/settings-read.js"; export { runSingle, type SingleConfig, type SingleOutcome } from "./flows/single.js"; - +export { + concludeSnapshot, + type FinishedSnapshot, + runSnapshot, + SNAPSHOT_RESULTS, + SNAPSHOT_SCHEMA_URL, + type SnapshotConfig, + type SnapshotRunResult, + type SnapshotTargetView, +} from "./flows/snapshot.js"; export { type ApiError, DEFAULT_API_VERSION, diff --git a/src/problem.ts b/src/problem.ts index 912b1e9dd..4efae0d12 100644 --- a/src/problem.ts +++ b/src/problem.ts @@ -90,6 +90,17 @@ export type Problem = readonly inputs: readonly string[]; readonly mode: EngineMode; } + | { + readonly code: "input-snapshot-only"; + readonly inputs: readonly string[]; + readonly mode: EngineMode; + } + | { readonly code: "input-rejected-in-snapshot"; readonly inputs: readonly string[] } + | { readonly code: "input-snapshot-destination-missing" } + | { readonly code: "input-snapshot-destinations-both" } + | { readonly code: "input-snapshot-file-with-multi" } + | { readonly code: "input-repository-with-snapshot-dir" } + | { readonly code: "input-snapshot-dir-without-targets" } | { readonly code: "input-token-missing" } | { readonly code: "input-report-without-redaction" } | { @@ -104,7 +115,7 @@ export type Problem = readonly code: "discovery-filters-without-wildcard"; readonly filters: readonly string[]; /** Where the run's targets come from instead of `repos: "*"`. */ - readonly targets: "single-repo" | "explicit-repos" | "repos-dir"; + readonly targets: "single-repo" | "explicit-repos" | "repos-dir" | "snapshot-file"; } | { readonly code: "input-defaults-file-without-multi" } | { @@ -182,6 +193,16 @@ export type Problem = readonly layer: string; } | { readonly code: "merged-file-unwritable"; readonly path: string; readonly reason: string } + | { + readonly code: "snapshot-file-is-settings-file"; + readonly snapshotFile: string; + readonly settingsFile: string; + } + | { + readonly code: "snapshot-dir-overlaps-repos-dir"; + readonly snapshotDir: string; + readonly reposDir: string; + } | { readonly code: "no-targets"; readonly filteredOut: number } // Target resolution | { readonly code: "repo-slug-invalid"; readonly value: string } @@ -288,6 +309,8 @@ function describeFiltersWithoutWildcard( return `the discovery filter input(s) ${named} only apply when repos is "*", but the "repos" input lists explicit repositories. Set repos: "*", or remove the filter input(s)`; case "repos-dir": return `the discovery filter input(s) ${named} only apply to repos: "*" discovery, but targets come only from repos-dir files. Set repos: "*", or remove the filter input(s)`; + case "snapshot-file": + return `the discovery filter input(s) ${named} only apply to repos: "*" discovery, but this snapshot targets one repository. Set repos: "*" with snapshot-dir to discover repositories, or remove the filter input(s)`; } } @@ -379,6 +402,29 @@ export function describeProblem(problem: Problem): string { `${problem.mode} mode, so ${problem.inputs.length === 1 ? "it" : "they"} would never be ` + "used. Remove the input(s), or set mode: merge to fold settings files" ); + case "input-snapshot-only": + return ( + `the ${quoteList(problem.inputs)} input(s) only apply to mode: snapshot, but this run is in ` + + `${problem.mode} mode, so ${problem.inputs.length === 1 ? "it" : "they"} would never be ` + + "used. Remove the input(s), or set mode: snapshot to write the live settings to a file" + ); + case "input-rejected-in-snapshot": + return ( + `the ${quoteList(problem.inputs)} input(s) do not apply to mode: snapshot, which only reads the ` + + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + + "the apply or check step that runs the snapshot" + ); + case "input-snapshot-destination-missing": + return 'mode: snapshot needs exactly one of the "snapshot-file" input (one repository\'s settings written to that file) or the "snapshot-dir" input (one /.yml per repos or repos-dir target under that directory). Set one of them'; + case "input-snapshot-destinations-both": + return 'the "snapshot-file" and "snapshot-dir" inputs are both set, but a snapshot run writes one form: a single repository to snapshot-file, or one /.yml per multi-repo target under snapshot-dir. Remove one of them'; + case "input-snapshot-file-with-multi": + return 'the "snapshot-file" input writes one repository\'s snapshot, but "repos" or "repos-dir" names multi-repo targets. Set "snapshot-dir" to write one file per target, or remove the multi-repo inputs and name the repository with "repository"'; + case "input-repository-with-snapshot-dir": + return 'the "repository" input cannot be combined with "snapshot-dir", which writes one file per "repos" or "repos-dir" target. Remove "repository", or set "snapshot-file" to snapshot one repository'; + case "input-snapshot-dir-without-targets": + return 'the "snapshot-dir" input needs multi-repo targets: set "repos" (an owner/name list, or "*" to discover) or "repos-dir". To snapshot one repository, set "snapshot-file" instead'; case "input-token-missing": return 'cannot call the GitHub API: no token was provided. Set the "token" input on the action step (or export GITHUB_TOKEN)'; case "input-report-without-redaction": @@ -443,6 +489,15 @@ export function describeProblem(problem: Problem): string { ); case "merged-file-unwritable": return `cannot write the merged document to ${problem.path}: ${problem.reason}. Check that the "merged-file" input names a writable path`; + case "snapshot-file-is-settings-file": + return `the "snapshot-file" input "${problem.snapshotFile}" is the settings file apply and check read (${problem.settingsFile}): the snapshot would overwrite the document you author. Write it to another path and copy it over deliberately`; + case "snapshot-dir-overlaps-repos-dir": + return ( + `the "snapshot-dir" input "${problem.snapshotDir}" is, contains, or sits inside the "repos-dir" ` + + `"${problem.reposDir}": the snapshots are written in the repos-dir layout, so they would ` + + "overwrite the central settings files or be read back as central files. Write them to a " + + "directory outside the repos-dir and copy them over deliberately" + ); case "no-targets": return problem.filteredOut > 0 ? `multi-repo mode found no targets: repos: "*" discovery found ${problem.filteredOut} ` + diff --git a/src/sections/actions_secrets/scenarios/actions_secrets-snapshot-roundtrip.yml b/src/sections/actions_secrets/scenarios/actions_secrets-snapshot-roundtrip.yml new file mode 100644 index 000000000..ec6218420 --- /dev/null +++ b/src/sections/actions_secrets/scenarios/actions_secrets-snapshot-roundtrip.yml @@ -0,0 +1,37 @@ +name: actions_secrets-snapshot-roundtrip +description: "mode: snapshot reads the live actions_secrets back into snapshot.yml and writes nothing to GitHub. The actions_secrets names read back as per-store $SECRET_ACTIONS_ references, one note each in the file header and as a notice; the values, which GitHub never reveals, come from the environment when the snapshot is checked back. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + actions_secrets: + - name: DEPLOY_TOKEN + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z + - name: GITHUB_PAT + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +env: + SECRET_ACTIONS_DEPLOY_TOKEN: deploy-token-plaintext-7f3a + SECRET_ACTIONS_GITHUB_PAT: pat-plaintext-91c2 +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: actions_secrets +expect: + exit_code: 0 + result: snapshot + outcomes: + actions_secrets: snapshot + snapshot: + actions_secrets: + _undeclared: keep + entries: + - name: DEPLOY_TOKEN + value: $SECRET_ACTIONS_DEPLOY_TOKEN + - name: GITHUB_PAT + value: $SECRET_ACTIONS_GITHUB_PAT + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/actions_variables/scenarios/actions_variables-snapshot-roundtrip.yml b/src/sections/actions_variables/scenarios/actions_variables-snapshot-roundtrip.yml new file mode 100644 index 000000000..b31d44ae0 --- /dev/null +++ b/src/sections/actions_variables/scenarios/actions_variables-snapshot-roundtrip.yml @@ -0,0 +1,30 @@ +name: actions_variables-snapshot-roundtrip +description: "mode: snapshot reads the live actions_variables back into snapshot.yml and writes nothing to GitHub. The actions_variables read back as name and value, the timestamps dropped. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + actions_variables: + - name: REGION + value: eu-west-1 + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: actions_variables +expect: + exit_code: 0 + result: snapshot + outcomes: + actions_variables: snapshot + snapshot: + actions_variables: + _undeclared: delete + entries: + - name: REGION + value: eu-west-1 + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/agents_secrets/scenarios/agents_secrets-snapshot-roundtrip.yml b/src/sections/agents_secrets/scenarios/agents_secrets-snapshot-roundtrip.yml new file mode 100644 index 000000000..3235032bc --- /dev/null +++ b/src/sections/agents_secrets/scenarios/agents_secrets-snapshot-roundtrip.yml @@ -0,0 +1,37 @@ +name: agents_secrets-snapshot-roundtrip +description: "mode: snapshot reads the live agents_secrets back into snapshot.yml and writes nothing to GitHub. The agents_secrets names read back as per-store $SECRET_AGENTS_ references, one note each in the file header and as a notice; the values, which GitHub never reveals, come from the environment when the snapshot is checked back. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + agents_secrets: + - name: DEPLOY_TOKEN + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z + - name: GITHUB_PAT + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +env: + SECRET_AGENTS_DEPLOY_TOKEN: deploy-token-plaintext-7f3a + SECRET_AGENTS_GITHUB_PAT: pat-plaintext-91c2 +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: agents_secrets +expect: + exit_code: 0 + result: snapshot + outcomes: + agents_secrets: snapshot + snapshot: + agents_secrets: + _undeclared: keep + entries: + - name: DEPLOY_TOKEN + value: $SECRET_AGENTS_DEPLOY_TOKEN + - name: GITHUB_PAT + value: $SECRET_AGENTS_GITHUB_PAT + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/agents_variables/scenarios/agents_variables-snapshot-roundtrip.yml b/src/sections/agents_variables/scenarios/agents_variables-snapshot-roundtrip.yml new file mode 100644 index 000000000..db252d32f --- /dev/null +++ b/src/sections/agents_variables/scenarios/agents_variables-snapshot-roundtrip.yml @@ -0,0 +1,30 @@ +name: agents_variables-snapshot-roundtrip +description: "mode: snapshot reads the live agents_variables back into snapshot.yml and writes nothing to GitHub. The agents_variables read back as name and value, the timestamps dropped. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + agents_variables: + - name: REGION + value: eu-west-1 + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: agents_variables +expect: + exit_code: 0 + result: snapshot + outcomes: + agents_variables: snapshot + snapshot: + agents_variables: + _undeclared: delete + entries: + - name: REGION + value: eu-west-1 + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/autolinks/scenarios/autolinks-snapshot-roundtrip.yml b/src/sections/autolinks/scenarios/autolinks-snapshot-roundtrip.yml new file mode 100644 index 000000000..0949f5b98 --- /dev/null +++ b/src/sections/autolinks/scenarios/autolinks-snapshot-roundtrip.yml @@ -0,0 +1,29 @@ +name: autolinks-snapshot-roundtrip +description: "mode: snapshot reads the live autolinks back into snapshot.yml and writes nothing to GitHub. A live autolink reads back with the is_alphanumeric flag GitHub reports. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + autolinks: + - key_prefix: JIRA- + url_template: https://jira.example.com/ +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: autolinks +expect: + exit_code: 0 + result: snapshot + outcomes: + autolinks: snapshot + snapshot: + autolinks: + _undeclared: delete + entries: + - key_prefix: JIRA- + url_template: https://jira.example.com/ + is_alphanumeric: true + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/code_quality_setup/scenarios/code_quality_setup-snapshot-roundtrip.yml b/src/sections/code_quality_setup/scenarios/code_quality_setup-snapshot-roundtrip.yml new file mode 100644 index 000000000..ba5ec1c92 --- /dev/null +++ b/src/sections/code_quality_setup/scenarios/code_quality_setup-snapshot-roundtrip.yml @@ -0,0 +1,24 @@ +name: code_quality_setup-snapshot-roundtrip +description: "mode: snapshot reads the live code_quality_setup back into snapshot.yml and writes nothing to GitHub. The not-configured default reads back with the null runner_type omitted (the slice takes no null there) and the nullable runner_label kept. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: {} +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: code_quality_setup +expect: + exit_code: 0 + result: snapshot + outcomes: + code_quality_setup: snapshot + snapshot: + code_quality_setup: + state: not-configured + languages: [] + runner_label: null + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/code_scanning_default_setup/scenarios/code_scanning_default_setup-snapshot-roundtrip.yml b/src/sections/code_scanning_default_setup/scenarios/code_scanning_default_setup-snapshot-roundtrip.yml new file mode 100644 index 000000000..cdc714863 --- /dev/null +++ b/src/sections/code_scanning_default_setup/scenarios/code_scanning_default_setup-snapshot-roundtrip.yml @@ -0,0 +1,38 @@ +name: code_scanning_default_setup-snapshot-roundtrip +description: "mode: snapshot reads the live code_scanning_default_setup back into snapshot.yml and writes nothing to GitHub. A configured default setup reads back as its declarable fields; updated_at and the schedule fall away. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + code_scanning: + state: configured + languages: + - javascript-typescript + runner_type: standard + runner_label: null + query_suite: default + threat_model: remote + updated_at: 2026-07-01T10:00:00Z + schedule: null +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: code_scanning_default_setup +expect: + exit_code: 0 + result: snapshot + outcomes: + code_scanning_default_setup: snapshot + snapshot: + code_scanning_default_setup: + state: configured + query_suite: default + languages: + - javascript-typescript + runner_type: standard + runner_label: null + threat_model: remote + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/codespaces_secrets/scenarios/codespaces_secrets-snapshot-roundtrip.yml b/src/sections/codespaces_secrets/scenarios/codespaces_secrets-snapshot-roundtrip.yml new file mode 100644 index 000000000..83953ce6e --- /dev/null +++ b/src/sections/codespaces_secrets/scenarios/codespaces_secrets-snapshot-roundtrip.yml @@ -0,0 +1,37 @@ +name: codespaces_secrets-snapshot-roundtrip +description: "mode: snapshot reads the live codespaces_secrets back into snapshot.yml and writes nothing to GitHub. The codespaces_secrets names read back as per-store $SECRET_CODESPACES_ references, one note each in the file header and as a notice; the values, which GitHub never reveals, come from the environment when the snapshot is checked back. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + codespaces_secrets: + - name: DEPLOY_TOKEN + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z + - name: GITHUB_PAT + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +env: + SECRET_CODESPACES_DEPLOY_TOKEN: deploy-token-plaintext-7f3a + SECRET_CODESPACES_GITHUB_PAT: pat-plaintext-91c2 +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: codespaces_secrets +expect: + exit_code: 0 + result: snapshot + outcomes: + codespaces_secrets: snapshot + snapshot: + codespaces_secrets: + _undeclared: keep + entries: + - name: DEPLOY_TOKEN + value: $SECRET_CODESPACES_DEPLOY_TOKEN + - name: GITHUB_PAT + value: $SECRET_CODESPACES_GITHUB_PAT + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/custom_properties/scenarios/custom_properties-snapshot-roundtrip.yml b/src/sections/custom_properties/scenarios/custom_properties-snapshot-roundtrip.yml new file mode 100644 index 000000000..3320104a7 --- /dev/null +++ b/src/sections/custom_properties/scenarios/custom_properties-snapshot-roundtrip.yml @@ -0,0 +1,38 @@ +name: custom_properties-snapshot-roundtrip +description: "mode: snapshot reads the live custom_properties back into snapshot.yml and writes nothing to GitHub. Set property values read back; a property with a null value is unset and omitted. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + custom_property_values: + - property_name: tier + value: gold + - property_name: compliance + value: + - soc2 + - hipaa + - property_name: pilot + value: null +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: custom_properties +expect: + exit_code: 0 + result: snapshot + outcomes: + custom_properties: snapshot + snapshot: + custom_properties: + _undeclared: keep + entries: + - property_name: tier + value: gold + - property_name: compliance + value: + - soc2 + - hipaa + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/dependabot_secrets/scenarios/dependabot_secrets-snapshot-roundtrip.yml b/src/sections/dependabot_secrets/scenarios/dependabot_secrets-snapshot-roundtrip.yml new file mode 100644 index 000000000..2de088cfc --- /dev/null +++ b/src/sections/dependabot_secrets/scenarios/dependabot_secrets-snapshot-roundtrip.yml @@ -0,0 +1,37 @@ +name: dependabot_secrets-snapshot-roundtrip +description: "mode: snapshot reads the live dependabot_secrets back into snapshot.yml and writes nothing to GitHub. The dependabot_secrets names read back as per-store $SECRET_DEPENDABOT_ references, one note each in the file header and as a notice; the values, which GitHub never reveals, come from the environment when the snapshot is checked back. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + dependabot_secrets: + - name: DEPLOY_TOKEN + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z + - name: GITHUB_PAT + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +env: + SECRET_DEPENDABOT_DEPLOY_TOKEN: deploy-token-plaintext-7f3a + SECRET_DEPENDABOT_GITHUB_PAT: pat-plaintext-91c2 +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: dependabot_secrets +expect: + exit_code: 0 + result: snapshot + outcomes: + dependabot_secrets: snapshot + snapshot: + dependabot_secrets: + _undeclared: keep + entries: + - name: DEPLOY_TOKEN + value: $SECRET_DEPENDABOT_DEPLOY_TOKEN + - name: GITHUB_PAT + value: $SECRET_DEPENDABOT_GITHUB_PAT + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/deploy_keys/scenarios/deploy_keys-snapshot-roundtrip.yml b/src/sections/deploy_keys/scenarios/deploy_keys-snapshot-roundtrip.yml new file mode 100644 index 000000000..7fa382a1d --- /dev/null +++ b/src/sections/deploy_keys/scenarios/deploy_keys-snapshot-roundtrip.yml @@ -0,0 +1,35 @@ +name: deploy_keys-snapshot-roundtrip +description: "mode: snapshot reads the live deploy_keys back into snapshot.yml and writes nothing to GitHub. Live deploy keys read back by title, key (the comment GitHub drops is dropped here too), and read_only flag. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + deploy_keys: + - title: ci-deploy + key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample deploy@ci + - title: read-write + key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOther + read_only: false +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: deploy_keys +expect: + exit_code: 0 + result: snapshot + outcomes: + deploy_keys: snapshot + snapshot: + deploy_keys: + _undeclared: keep + entries: + - title: ci-deploy + key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample + read_only: false + - title: read-write + key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOther + read_only: false + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/interaction_limits/scenarios/interaction_limits-snapshot-roundtrip.yml b/src/sections/interaction_limits/scenarios/interaction_limits-snapshot-roundtrip.yml new file mode 100644 index 000000000..ea83affab --- /dev/null +++ b/src/sections/interaction_limits/scenarios/interaction_limits-snapshot-roundtrip.yml @@ -0,0 +1,36 @@ +name: interaction_limits-snapshot-roundtrip +description: "mode: snapshot reads the live interaction_limits back into snapshot.yml and writes nothing to GitHub. The live limit, the pull-request creation cap, and its bypass list read back; the expiry cannot (GitHub reports only the computed expires_at), which the header notes. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + interaction_limits: + limit: collaborators_only + origin: repository + expires_at: 2027-01-02T00:00:00Z + pull_creation_cap: + enabled: true + max_open_pull_requests: 5 + pull_bypass_list: + - login: octocat +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: interaction_limits +expect: + exit_code: 0 + result: snapshot + outcomes: + interaction_limits: snapshot + snapshot: + interaction_limits: + limit: collaborators_only + pull_request_creation_cap: + enabled: true + max_open_pull_requests: 5 + pull_request_creation_bypass: + - octocat + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/labels/scenarios/labels-snapshot-roundtrip.yml b/src/sections/labels/scenarios/labels-snapshot-roundtrip.yml new file mode 100644 index 000000000..9672d24c3 --- /dev/null +++ b/src/sections/labels/scenarios/labels-snapshot-roundtrip.yml @@ -0,0 +1,35 @@ +name: labels-snapshot-roundtrip +description: "mode: snapshot reads the live labels back into snapshot.yml and writes nothing to GitHub. Every live label reads back with its color lower-cased and GitHub's null description spelled as the empty string the file takes. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + labels: + - name: bug + color: d73a4a + description: Something is broken + - name: docs + color: 0075CA +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: labels +expect: + exit_code: 0 + result: snapshot + outcomes: + labels: snapshot + snapshot: + labels: + _undeclared: delete + entries: + - name: bug + color: d73a4a + description: Something is broken + - name: docs + color: 0075ca + description: "" + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/milestones/scenarios/milestones-snapshot-roundtrip.yml b/src/sections/milestones/scenarios/milestones-snapshot-roundtrip.yml new file mode 100644 index 000000000..5022148b5 --- /dev/null +++ b/src/sections/milestones/scenarios/milestones-snapshot-roundtrip.yml @@ -0,0 +1,39 @@ +name: milestones-snapshot-roundtrip +description: "mode: snapshot reads the live milestones back into snapshot.yml and writes nothing to GitHub. Open and closed milestones read back by title, description, and state; a null description is omitted. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + milestones: + - id: 1 + number: 1 + title: v1.0 + state: open + description: First stable release. + - id: 2 + number: 2 + title: v2.0 + state: closed + description: null +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: milestones +expect: + exit_code: 0 + result: snapshot + outcomes: + milestones: snapshot + snapshot: + milestones: + _undeclared: keep + entries: + - title: v1.0 + description: First stable release. + state: open + - title: v2.0 + state: closed + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/pages/scenarios/pages-snapshot-roundtrip.yml b/src/sections/pages/scenarios/pages-snapshot-roundtrip.yml new file mode 100644 index 000000000..0fb95d69c --- /dev/null +++ b/src/sections/pages/scenarios/pages-snapshot-roundtrip.yml @@ -0,0 +1,47 @@ +name: pages-snapshot-roundtrip +description: "mode: snapshot reads the live pages back into snapshot.yml and writes nothing to GitHub. The live Pages site reads back as its declarable fields; the server-owned ones (url, status, html_url, the certificate) fall away. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + pages: + url: https://api.github.com/repos/e2e-owner/e2e-repo/pages + status: built + cname: docs.example.com + custom_404: false + html_url: https://e2e-owner.github.io/e2e-repo/ + build_type: workflow + source: + branch: main + path: / + public: true + https_certificate: + state: approved + description: ok + domains: + - docs.example.com + https_enforced: true + protected_domain_state: null + pending_domain_unverified_at: null +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: pages +expect: + exit_code: 0 + result: snapshot + outcomes: + pages: snapshot + snapshot: + pages: + build_type: workflow + source: + branch: main + path: / + cname: docs.example.com + https_enforced: true + public: true + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/secret_scanning_custom_patterns/scenarios/secret_scanning_custom_patterns-snapshot-roundtrip.yml b/src/sections/secret_scanning_custom_patterns/scenarios/secret_scanning_custom_patterns-snapshot-roundtrip.yml new file mode 100644 index 000000000..990ee9ccd --- /dev/null +++ b/src/sections/secret_scanning_custom_patterns/scenarios/secret_scanning_custom_patterns-snapshot-roundtrip.yml @@ -0,0 +1,40 @@ +name: secret_scanning_custom_patterns-snapshot-roundtrip +description: "mode: snapshot reads the live secret_scanning_custom_patterns back into snapshot.yml and writes nothing to GitHub. A published custom pattern reads back as its declarable fields; the server-assigned id, slug, version, and state fall away. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + secret_scanning_patterns: + - id: 1 + name: acme-token + slug: acme-token + pattern: acme_[a-z0-9]{32} + start_delimiter: \A|[^0-9A-Za-z] + must_match: + - "[a-z]" + must_not_match: null + state: published + push_protection_enabled: false + custom_pattern_version: v1 +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: secret_scanning_custom_patterns +expect: + exit_code: 0 + result: snapshot + outcomes: + secret_scanning_custom_patterns: snapshot + snapshot: + secret_scanning_custom_patterns: + _undeclared: keep + entries: + - name: acme-token + pattern: acme_[a-z0-9]{32} + start_delimiter: \A|[^0-9A-Za-z] + must_match: + - "[a-z]" + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/webhooks/scenarios/webhooks-snapshot-roundtrip.yml b/src/sections/webhooks/scenarios/webhooks-snapshot-roundtrip.yml new file mode 100644 index 000000000..f4b4febcf --- /dev/null +++ b/src/sections/webhooks/scenarios/webhooks-snapshot-roundtrip.yml @@ -0,0 +1,55 @@ +name: webhooks-snapshot-roundtrip +description: "mode: snapshot reads the live webhooks back into snapshot.yml and writes nothing to GitHub. Live webhooks read back with their config, events, and active flag; the secret GitHub never reveals becomes a $WEBHOOK_SECRET_ reference with a note, and the value comes from the environment when the snapshot is checked back. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + hooks: + - id: 601 + events: + - push + - pull_request + config: + url: https://ci.example.com/hook + content_type: json + insecure_ssl: "0" + secret: the-live-secret + - id: 602 + active: false + config: + url: https://deploy.example.com/hook +env: + WEBHOOK_SECRET_601: webhook-secret-plaintext-4d1e +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: webhooks +expect: + exit_code: 0 + result: snapshot + outcomes: + webhooks: snapshot + snapshot: + webhooks: + _undeclared: keep + entries: + - name: web + config: + url: https://ci.example.com/hook + content_type: json + insecure_ssl: "0" + secret: $WEBHOOK_SECRET_601 + events: + - push + - pull_request + active: true + - name: web + config: + url: https://deploy.example.com/hook + events: + - push + active: false + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/src/sections/workflows/scenarios/workflows-snapshot-roundtrip.yml b/src/sections/workflows/scenarios/workflows-snapshot-roundtrip.yml new file mode 100644 index 000000000..79cbe5cad --- /dev/null +++ b/src/sections/workflows/scenarios/workflows-snapshot-roundtrip.yml @@ -0,0 +1,38 @@ +name: workflows-snapshot-roundtrip +description: "mode: snapshot reads the live workflows back into snapshot.yml and writes nothing to GitHub. Active and manually disabled workflows read back by path and state; a deleted one has no file to speak for and is omitted. The written file, fed back to a check run against the same state, reads clean." +settings: {} +live_state: + workflows: + - id: 1 + name: CI + path: .github/workflows/ci.yml + state: active + - id: 2 + name: Old + path: .github/workflows/old.yml + state: disabled_manually + - id: 3 + name: Gone + path: .github/workflows/gone.yml + state: deleted +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: workflows +expect: + exit_code: 0 + result: snapshot + outcomes: + workflows: snapshot + snapshot: + workflows: + - path: .github/workflows/ci.yml + state: active + - path: .github/workflows/old.yml + state: disabled + never: + - POST / + - PATCH / + - PUT / + - DELETE / + snapshot_converges: true diff --git a/test/action/action-yml.test.ts b/test/action/action-yml.test.ts index de6e424c0..8e82d9619 100644 --- a/test/action/action-yml.test.ts +++ b/test/action/action-yml.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js"; import { REPO_RESULTS } from "../../src/engine/orchestrate.js"; import { MERGE_RESULT } from "../../src/flows/deliver.js"; import { FILTER_INPUTS, INPUT_DECLS, type InputDecl } from "../../src/flows/inputs.js"; +import { SNAPSHOT_RESULTS } from "../../src/flows/snapshot.js"; const ROOT = join(import.meta.dir, "..", ".."); @@ -55,9 +56,9 @@ describe("input declarations <-> discovery defaults", () => { }); describe("output declarations", () => { - test("the result description mentions every RepoResult value and the merge result", () => { - // MERGE_RESULT is the one value outside REPO_RESULTS (a merge has no target). - const missing = [...REPO_RESULTS, MERGE_RESULT].filter( + test("the result description mentions every RepoResult value, the merge result, and the snapshot results", () => { + // MERGE_RESULT and SNAPSHOT_RESULTS are the values outside REPO_RESULTS (neither mode has a target). + const missing = [...REPO_RESULTS, MERGE_RESULT, ...SNAPSHOT_RESULTS].filter( (value) => !OUTPUT_DECLS.result.description.includes(value), ); expect( diff --git a/test/action/run.test.ts b/test/action/run.test.ts index 2533a59af..e4240bc41 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -805,3 +805,95 @@ describe("run in mode: merge", () => { ]); }); }); + +describe("run in mode: snapshot", () => { + const ENV_KEYS = [ + "INPUT_TOKEN", + "INPUT_MODE", + "INPUT_REPOSITORY", + "INPUT_SETTINGS-FILE", + "INPUT_SNAPSHOT-FILE", + "INPUT_SECTIONS", + "GITHUB_REPOSITORY", + ]; + const saved = new Map(ENV_KEYS.map((k) => [k, process.env[k]])); + let dir: string; + let snapshotFile: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "snapshot-mode-")); + snapshotFile = join(dir, "out", "snapshot.yml"); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + /** A snapshot run's env: the token, the target, the file, the labels allowlist. */ + function setSnapshotEnv() { + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.INPUT_TOKEN = "t"; + process.env.INPUT_MODE = "snapshot"; + // The workflow's own repository: the self carve-out skips the visibility + // probe, so the target is shown without a repository route. + process.env.GITHUB_REPOSITORY = "o/r"; + process.env.INPUT_REPOSITORY = "o/r"; + process.env["INPUT_SNAPSHOT-FILE"] = snapshotFile; + process.env.INPUT_SECTIONS = "labels"; + } + + const LABELS = [{ name: "bug", color: "d73a4a", description: "Something is broken" }]; + const labelsApi = () => + new MockApi({ "GET /repos/o/r/labels?per_page=100&page=1": { data: LABELS } }); + + test("writes the live settings to snapshot-file and publishes the snapshot result", async () => { + setSnapshotEnv(); + const api = labelsApi(); + expect(await run({ api, io: testIo })).toBe(0); + expect(api.mutations()).toEqual([]); + expect(parseYaml(readFileSync(snapshotFile, "utf8"))).toEqual({ + labels: { _undeclared: "delete", entries: LABELS }, + }); + expect(outputs).toEqual({ "skipped-sections": "", result: "snapshot" }); + expect(captured).toEqual([`snapshot written to ${snapshotFile}`, "result: snapshot"]); + }); + + test("an apply-time input is rejected before any API call, and no file is written", async () => { + setSnapshotEnv(); + process.env["INPUT_SETTINGS-FILE"] = "other.yml"; + const api = labelsApi(); + expect(await run({ api, io: testIo })).toBe(1); + expect(api.calls).toEqual([]); + expect(existsSync(snapshotFile)).toBe(false); + expect(captured).toEqual([ + 'error: the "settings-file" input(s) do not apply to mode: snapshot, which only reads the target repositories\' live settings into snapshot-file or snapshot-dir: it applies no document, folds no layers, and delivers no report. Remove the input(s), or move them to the apply or check step that runs the snapshot', + "result: failed", + ]); + }); + + test("snapshot then check: the written file checks clean against the live state it was read from", async () => { + setSnapshotEnv(); + expect(await run({ api: labelsApi(), io: testIo })).toBe(0); + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.INPUT_TOKEN = "t"; + process.env.INPUT_MODE = "check"; + process.env.GITHUB_REPOSITORY = "o/r"; + process.env.INPUT_REPOSITORY = "o/r"; + process.env.INPUT_SECTIONS = "labels"; + process.env["INPUT_SETTINGS-FILE"] = snapshotFile; + const api = labelsApi(); + expect(await run({ api, io: testIo })).toBe(0); + expect(api.mutations()).toEqual([]); + expect(outputs.result).toBe("clean"); + }); +}); diff --git a/test/docs/guides.test.ts b/test/docs/guides.test.ts index 886c254fe..46450e098 100644 --- a/test/docs/guides.test.ts +++ b/test/docs/guides.test.ts @@ -6,7 +6,7 @@ import { ok } from "neverthrow"; import { parse as parseYaml } from "yaml"; import { type Layer, mergeLayers, stripNulls } from "../../src/engine/layers.js"; import { validateSettingsDoc } from "../../src/engine/orchestrate.js"; -import { MERGE_REJECTED_INPUTS } from "../../src/flows/inputs.js"; +import { MERGE_REJECTED_INPUTS, SNAPSHOT_REJECTED_INPUTS } from "../../src/flows/inputs.js"; import { foldLayers } from "../../src/flows/layers.js"; import { silentIo } from "../../src/io.js"; import { describeProblem } from "../../src/problem.js"; @@ -38,6 +38,7 @@ const REQUIRED_PAGES = [ "reference/architecture.md", "reference/library.md", "operate/check-mode.md", + "operate/snapshot.md", "operate/multi-repo.md", "operate/layering.md", "operate/private-repositories.md", @@ -464,11 +465,14 @@ describe("docs/ guide pages", () => { expect(offenders).toEqual([]); }); - /** Every file release-please's generic updater may rewrite; both marker tests iterate this one list. */ + /** Every file release-please's generic updater may rewrite, the snapshot flow's schema-hint source included; both marker tests iterate this one list. */ function markerScanFiles(): Array<{ label: string; path: string }> { const rootPages = readdirSync(ROOT) .filter((name) => name.endsWith(".md")) - .map((name) => ({ label: name, path: join(ROOT, name) })); + .map((name) => ({ label: name, path: join(ROOT, name) })) + .concat([ + { label: "src/flows/snapshot.ts", path: join(ROOT, "src", "flows", "snapshot.ts") }, + ]); const templateDir = join(ROOT, ".github", "ISSUE_TEMPLATE"); const templates = readdirSync(templateDir) .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")) @@ -654,6 +658,20 @@ describe("docs/ guide pages", () => { expect(new Set(named)).toEqual(new Set(MERGE_REJECTED_INPUTS)); }); + test("the snapshot guide's inputs table names every input mode: snapshot rejects", () => { + // Same derivation as the merge pin: a new apply/check-time input is + // rejected by the snapshot the moment it is declared, and the table must + // name it or the page under-reports the refusal. + const markdown = readFileSync(join(DOCS, "operate", "snapshot.md"), "utf8"); + const section = sectionLines(markdown, "Inputs in mode: snapshot", "docs/operate/snapshot.md"); + const rejectedRow = section.find((line) => line.includes("| Rejected")); + if (rejectedRow === undefined) { + throw new Error('docs/operate/snapshot.md has no "Rejected" row in its inputs table'); + } + const named = [...rejectedRow.matchAll(/`([a-z-]+)`/g)].map((match) => match[1]); + expect(new Set(named)).toEqual(new Set(SNAPSHOT_REJECTED_INPUTS)); + }); + describe("the layering guide's refusal tables quote the messages the merge step emits", () => { // The quoted message is compared against the whole error its layer raises (wrapped in that layer's prefix), so no row can quote a message its // own layer does not raise. diff --git a/test/docs/readme.test.ts b/test/docs/readme.test.ts index 39f2ff267..ee6fe06f3 100644 --- a/test/docs/readme.test.ts +++ b/test/docs/readme.test.ts @@ -6,6 +6,7 @@ import { countWord } from "../../.github/scripts/lib/count-word.js"; import { REPO_RESULTS } from "../../src/engine/orchestrate.js"; import { DEFAULT_PRIVATE_REPOS } from "../../src/flows/inputs.js"; import { REDACTED_DETAIL } from "../../src/flows/redact.js"; +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 { PROBOT_PARITY_KEYS, SECTION_KEYS } from "../../src/schema.js"; @@ -196,7 +197,11 @@ describe("schema $schema hints and $id", () => { const EXPECTED_HINTS: Record = { "README.md": 1, // the quick-start settings example "docs/start/getting-started.md": 1, + "docs/operate/snapshot.md": 1, // the example snapshot file }; + // The hint every snapshot file starts with is the same URL: the file a + // reader gets from mode: snapshot validates exactly as the quick start's does. + expect(SNAPSHOT_SCHEMA_URL).toBe(expectedHint); for (const page of hintPages()) { const markdown = readFileSync(page.path, "utf8"); const hints = [...markdown.matchAll(/yaml-language-server: \$schema=(\S+)/g)]; diff --git a/test/e2e/run.ts b/test/e2e/run.ts index 733a001b6..99fbb9279 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -32,20 +32,27 @@ function parseFlags(argv: string[]): Flags { } /** - * A multi scenario declares sections per target and a merge one may declare a section only in a lower - * layer, so filtering on settings alone would drop both. + * A multi scenario declares sections per target, a merge one may declare a section only in a lower + * layer, and a snapshot one declares none (its pinned documents and `sections` allowlist name them), + * so filtering on settings alone would drop all three. */ function scenarioSections(scenario: Scenario): Set { const keys = new Set(Object.keys(scenario.settings ?? {})); - for (const spec of Object.values(scenario.repos ?? {})) { - if (spec.settings) { - for (const key of Object.keys(spec.settings)) { - keys.add(key); - } + for (const key of (scenario.inputs?.sections ?? "").split(",")) { + if (key.trim() !== "") { + keys.add(key.trim()); } } - for (const doc of [scenario.defaults_file ?? {}, ...(scenario.settings_layers ?? [])]) { - for (const key of Object.keys(doc)) { + const docs: Array | undefined> = [ + scenario.defaults_file, + ...(scenario.settings_layers ?? []), + scenario.expect.snapshot, + ]; + for (const spec of Object.values(scenario.repos ?? {})) { + docs.push(spec.settings ?? undefined, spec.expect?.snapshot); + } + for (const doc of docs) { + for (const key of Object.keys(doc ?? {})) { keys.add(key); } } diff --git a/test/e2e/runner.ts b/test/e2e/runner.ts index b32597785..cf7349630 100644 --- a/test/e2e/runner.ts +++ b/test/e2e/runner.ts @@ -9,10 +9,11 @@ * process so a run never tests stale code */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { parseRepoSlug } from "../../src/discovery/targets.js"; import type { OutputName } from "../../src/io.js"; import { assertApplyIdempotent, @@ -225,20 +226,24 @@ function mergedFilePath(dir: string): string { return join(dir, "merged.yml"); } -function mergedFileFailures(path: string, expected: Record): string[] { +function documentFileFailures( + label: string, + path: string, + expected: Record, +): string[] { let live: unknown; try { live = parseYaml(readFileSync(path, "utf8")); } catch (error) { return [ - `merged file ${path} unreadable: ${error instanceof Error ? error.message : String(error)}`, + `${label} file ${path} unreadable: ${error instanceof Error ? error.message : String(error)}`, ]; } if (Bun.deepEquals(live, expected)) { return []; } if (typeof live !== "object" || live === null || Array.isArray(live)) { - return [`merged document is not a mapping: ${JSON.stringify(live)}`]; + return [`${label} document is not a mapping: ${JSON.stringify(live)}`]; } const record = live as Record; const keys = new Set([...Object.keys(expected), ...Object.keys(record)]); @@ -246,13 +251,41 @@ function mergedFileFailures(path: string, expected: Record): st for (const key of keys) { if (!Bun.deepEquals(record[key], expected[key])) { failures.push( - `merged.${key}: ${JSON.stringify(record[key])} != expected ${JSON.stringify(expected[key])}`, + `${label}.${key}: ${JSON.stringify(record[key])} != expected ${JSON.stringify(expected[key])}`, ); } } return failures; } +/** + * The snapshot files a dir-form scenario pins, each resolved to its + * `//.yml` path under the scenario's temp dir. + */ +function pinnedDirSnapshots( + scenario: Scenario, + dir: string, +): Array<{ slug: string; path: string; expected: Record }> { + const snapshotDir = scenario.inputs?.snapshot_dir; + if (snapshotDir === undefined) { + return []; + } + const pinned: Array<{ slug: string; path: string; expected: Record }> = []; + for (const [slug, spec] of Object.entries(scenario.repos ?? {})) { + const expected = spec.expect?.snapshot; + const repo = parseRepoSlug(slug); + if (expected === undefined || repo.isErr()) { + continue; + } + pinned.push({ + slug, + path: join(dir, snapshotDir, repo.value.owner, `${repo.value.name}.yml`), + expected, + }); + } + return pinned; +} + /** Built from scratch: only PATH and HOME are taken from process.env. */ function childEnv(scenario: Scenario, dir: string, apiUrl: string): NodeJS.ProcessEnv { const inputs = scenario.inputs ?? {}; @@ -271,8 +304,9 @@ function childEnv(scenario: Scenario, dir: string, apiUrl: string): NodeJS.Proce // A test knob: millisecond plugin units and the immediate scheduler, so retry scenarios run in milliseconds instead of seconds. RETRY_BASE_MS: "1", }; - // settings-file is a single-repo input the action rejects beside the multi-repo inputs. A merge - // lists every layer file below settings.yml, lowest first. + // settings-file is a single-repo input the action rejects beside the multi-repo inputs and in + // snapshot mode. A merge lists every layer file below settings.yml, lowest first; a snapshot names + // its destination relative to the child's working directory (this temp dir). if (inputs.mode === "merge") { const layers = (scenario.settings_layers ?? []).map((layer, i) => { const path = join(dir, `layer-${i}.yml`); @@ -281,6 +315,13 @@ function childEnv(scenario: Scenario, dir: string, apiUrl: string): NodeJS.Proce }); env["INPUT_SETTINGS-FILE"] = [...layers, join(dir, "settings.yml")].join("\n"); env["INPUT_MERGED-FILE"] = mergedFilePath(dir); + } else if (inputs.mode === "snapshot") { + if (inputs.snapshot_file !== undefined) { + env["INPUT_SNAPSHOT-FILE"] = inputs.snapshot_file; + } + if (inputs.snapshot_dir !== undefined) { + env["INPUT_SNAPSHOT-DIR"] = inputs.snapshot_dir; + } } else if (!multi) { env["INPUT_SETTINGS-FILE"] = join(dir, "settings.yml"); } @@ -520,7 +561,18 @@ export async function runScenario( failures.push(`result "${first.outputs.result}" != expected "${exp.result}"`); } if (exp.merged !== undefined) { - failures.push(...mergedFileFailures(mergedFilePath(dir), exp.merged)); + failures.push(...documentFileFailures("merged", mergedFilePath(dir), exp.merged)); + } + // 3-snapshot. The snapshot document(s) a mode: snapshot run wrote, each + // compared whole after a YAML parse, so the comment header is ignored. + const snapshotFile = scenario.inputs?.snapshot_file; + if (exp.snapshot !== undefined && snapshotFile !== undefined) { + failures.push(...documentFileFailures("snapshot", join(dir, snapshotFile), exp.snapshot)); + } + for (const pinned of pinnedDirSnapshots(scenario, dir)) { + failures.push( + ...documentFileFailures(`snapshot[${pinned.slug}]`, pinned.path, pinned.expected), + ); } // The `skipped-sections` output is compared as a set: the engine emits SECTION_KEYS order, which // the expectation should not restate. An ABSENT output fails even against an empty expectation: @@ -649,6 +701,52 @@ export async function runScenario( } } + // 8-snapshot. The written snapshot, fed back as the settings file of a CHECK run against the + // SAME seeded state, must read clean without a write. + if (exp.snapshot_converges && snapshotFile !== undefined) { + const violationsBefore = handle.violations.length; + const writesBefore = handle.requests.length; + handle.enterCheckMode(); + copyFileSync(join(dir, snapshotFile), join(dir, "settings.yml")); + const inputs = scenario.inputs ?? {}; + const check = await invoke( + { + ...scenario, + inputs: { + mode: "check", + ...(inputs.sections === undefined ? {} : { sections: inputs.sections }), + ...(inputs.on_missing_permission === undefined + ? {} + : { on_missing_permission: inputs.on_missing_permission }), + ...(inputs.private_repos === undefined ? {} : { private_repos: inputs.private_repos }), + }, + }, + dir, + handle.url, + ); + reruns.push(captureRerun("snapshot check", check)); + const newWrites = handle.requests.slice(writesBefore).filter(isWriteRequest); + if (check.exitCode !== 0) { + failures.push( + `snapshot round trip: the check exited ${check.exitCode}, expected 0${killNote(check)}`, + ); + } + if (check.outputs.result !== "clean") { + failures.push( + `snapshot round trip: the check's result is "${check.outputs.result}", expected "clean"`, + ); + } + if (newWrites.length > 0) { + failures.push( + `snapshot round trip: the check wrote ${newWrites.length} time(s): ${newWrites.map((r) => renderRequest(r, false)).join(", ")}`, + ); + } + const newViolations = handle.violations.slice(violationsBefore); + if (newViolations.length > 0) { + failures.push(`snapshot round trip: mock violations:\n ${newViolations.join("\n ")}`); + } + } + // Always on: the mock is our stand-in for GitHub, so any drift from the published contract is a mock bug. const openApiViolations = sharedValidator().validateLog(handle.requests); if (openApiViolations.length > 0) { diff --git a/test/e2e/scenarios/snapshot-denied-fails.yml b/test/e2e/scenarios/snapshot-denied-fails.yml new file mode 100644 index 000000000..7e15fd295 --- /dev/null +++ b/test/e2e/scenarios/snapshot-denied-fails.yml @@ -0,0 +1,35 @@ +name: snapshot-denied-fails +description: "A snapshot under the default on-missing-permission: fail with a section the token cannot read: the run fails with exit 1 and result failed, the denial is an error annotation, and no file is written (the summary says so)." +settings: {} +live_state: + labels: + - name: bug + color: d73a4a + description: Something is broken + actions_variables: + - name: REGION + value: eu-west-1 + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +token_permissions: + variables: none +inputs: + mode: snapshot + snapshot_file: snapshot.yml + sections: labels,actions_variables +expect: + exit_code: 1 + result: failed + skipped_sections: [] + outcomes: + labels: snapshot + actions_variables: failed + stdout_contains: + - "::error::actions_variables: not snapshotted - the token was denied GET /repos/e2e-owner/e2e-repo/actions/variables" + summary_contains: + - ":x: failed - the snapshot failed, so no file was written" + never: + - POST / + - PATCH / + - PUT / + - DELETE / diff --git a/test/e2e/scenarios/snapshot-dir-multi-repo.yml b/test/e2e/scenarios/snapshot-dir-multi-repo.yml new file mode 100644 index 000000000..c2e6fa4c5 --- /dev/null +++ b/test/e2e/scenarios/snapshot-dir-multi-repo.yml @@ -0,0 +1,77 @@ +name: snapshot-dir-multi-repo +description: "The dir form: the targets come from the repos input exactly as in a multi-repo apply, and each is written to //.yml, the repos-dir layout. A private target (redacted by default) gets its file like the others, while the public surfaces know it only as its placeholder: its slug, its file name, and its label values appear nowhere in the log, the summary, or the outputs." +settings: {} +repos: + e2e-owner/svc-a: + live_state: + labels: + - name: stable + color: 0e8a16 + expect: + result: snapshot + snapshot: + labels: + _undeclared: delete + entries: + - name: stable + color: 0e8a16 + description: "" + e2e-owner/svc-b: + live_state: + labels: + - &a1 + name: incident + color: b60205 + description: Page the on-call + expect: + result: snapshot + snapshot: + labels: + _undeclared: delete + entries: + - *a1 + e2e-owner/svc-private: + live_state: + repo: + private: true + visibility: private + labels: + - &a2 + name: hush-project + color: "000000" + description: Not for the public log + expect: + snapshot: + labels: + _undeclared: delete + entries: + - *a2 +inputs: + mode: snapshot + snapshot_dir: snapshots + sections: labels +expect: + exit_code: 0 + result: snapshot + repos_result: + e2e-owner/svc-a: snapshot + e2e-owner/svc-b: snapshot + "private repository #1": snapshot + summary_contains: + - "## github-settings-as-code (snapshot, 3 repositories)" + - "| e2e-owner/svc-a | remote | :white_check_mark: snapshot | snapshots/e2e-owner/svc-a.yml |" + - "| e2e-owner/svc-b | remote | :white_check_mark: snapshot | snapshots/e2e-owner/svc-b.yml |" + - "| private repository #1 | remote | :white_check_mark: snapshot | hidden (private repository) |" + stdout_contains: + - "e2e-owner/svc-a: snapshot written to snapshots/e2e-owner/svc-a.yml" + - "e2e-owner/svc-b: snapshot written to snapshots/e2e-owner/svc-b.yml" + leaks_nowhere: + - svc-private + - hush-project + - Not for the public log + - _undeclared + never: + - POST / + - PATCH / + - PUT / + - DELETE / diff --git a/test/e2e/scenarios/snapshot-unsupported-and-denied.yml b/test/e2e/scenarios/snapshot-unsupported-and-denied.yml new file mode 100644 index 000000000..6fc16b42b --- /dev/null +++ b/test/e2e/scenarios/snapshot-unsupported-and-denied.yml @@ -0,0 +1,50 @@ +name: snapshot-unsupported-and-denied +description: "A snapshot under on-missing-permission: warn with a section the token cannot read and one GitHub exposes no read endpoint for. The denied section is skipped (a warning, the skipped-sections output, the header line) and the run ends partial with exit 0; the unsupported one is a notice and a header line, never a failure; the file carries the section that read back. The document itself never reaches stdout or the summary." +settings: {} +live_state: + labels: + - &a1 + name: bug + color: d73a4a + description: Something is broken + actions_variables: + - name: REGION + value: eu-west-1 + created_at: 2026-01-01T00:00:00Z + updated_at: 2026-01-01T00:00:00Z +token_permissions: + variables: none +inputs: + mode: snapshot + snapshot_file: out/snapshot.yml + on_missing_permission: warn + sections: labels,actions_variables,check_suite_preferences +expect: + exit_code: 0 + result: partial + skipped_sections: + - actions_variables + outcomes: + labels: snapshot + actions_variables: skipped + check_suite_preferences: unsupported + snapshot: + labels: + _undeclared: delete + entries: + - *a1 + stdout_contains: + - "::warning::actions_variables: skipped - the token was denied GET /repos/e2e-owner/e2e-repo/actions/variables" + - "::notice::not snapshotted: check_suite_preferences - snapshot does not read these sections back, so the file omits them (the header says why); declare them by hand if they should be managed" + - snapshot written to out/snapshot.yml + summary_contains: + - ":warning: partial - written to out/snapshot.yml" + - "| check_suite_preferences | :fast_forward: unsupported | check_suite_preferences: GitHub exposes no read endpoint for this section, so there is nothing to snapshot; apply re-asserts the declared value on every run |" + leaks_nowhere: + - _undeclared + - Something is broken + never: + - POST / + - PATCH / + - PUT / + - DELETE / diff --git a/test/e2e/schema.ts b/test/e2e/schema.ts index ed460a6a4..bcae4f857 100644 --- a/test/e2e/schema.ts +++ b/test/e2e/schema.ts @@ -61,9 +61,16 @@ const OwnerKindSchema = z.enum(["org", "user"]); /** The action inputs a scenario can set; the list inputs stay comma-separated strings, the action's own wire format. */ const InputsSchema = z .object({ - mode: z.enum(["apply", "check", "merge"]).optional(), + mode: z.enum(["apply", "check", "merge", "snapshot"]).optional(), /** The mode: merge run default for the keyed list sections (INPUT_LAYERING). */ layering: z.enum(["merge", "replace"]).optional(), + /** + * mode: snapshot only, exactly one of the two: where the child writes, + * relative to the scenario's temp dir (its working directory), forwarded + * verbatim as INPUT_SNAPSHOT-FILE / INPUT_SNAPSHOT-DIR. + */ + snapshot_file: z.string().optional(), + snapshot_dir: z.string().optional(), on_missing_permission: z.enum(["fail", "warn"]).optional(), required_sections: z.string().optional(), sections: z.string().optional(), @@ -172,6 +179,19 @@ const ExpectSchema = z * YAML parse. A merge never runs the engine, so it cannot combine with a fixpoint re-run proof. */ merged: SettingsSchema.optional(), + /** + * mode: snapshot, file form only: the EXACT document the run must write to + * snapshot_file, compared whole after a YAML parse (so the comment header + * is ignored). A dir-form target pins its file under `repos..expect.snapshot`. + */ + snapshot: SettingsSchema.optional(), + /** + * mode: snapshot, file form only. When true, the runner re-runs the bundle + * in CHECK mode with the written snapshot as its settings file against the + * SAME seeded state (the allowlist and the denial policy carried over) and + * expects exit 0, `result: clean`, and zero writes: the round trip. + */ + snapshot_converges: z.boolean().optional(), }) .strict() // apply_idempotent's final check-mode run IS the convergence proof, so arming both would rerun it. @@ -192,6 +212,18 @@ const ExpectSchema = z (expected.converges === undefined && expected.apply_idempotent === undefined), { message: "set fixpoint or a legacy converges/apply_idempotent boolean, not both" }, ) + // A snapshot applies nothing, so the apply-mode fixpoint proofs have no run to re-run. + .refine( + (expected) => + (expected.snapshot === undefined && expected.snapshot_converges === undefined) || + (expected.converges === undefined && + expected.apply_idempotent === undefined && + expected.fixpoint === undefined), + { + message: + "snapshot and snapshot_converges pin a mode: snapshot run, which has no apply-mode fixpoint", + }, + ) // The two YAML booleans collapse into one armed proof, so consumers branch on a single enum instead // of two booleans whose exclusivity would otherwise live in a comment. .transform(({ converges, apply_idempotent, fixpoint, ...rest }) => ({ @@ -229,7 +261,14 @@ const MultiRepoSchema = z settings_raw: z.string().optional(), live_state: LiveStateSchema.optional(), permissions: TokenPermissionsSchema.optional(), - expect: z.object({ result: z.string().optional() }).strict().optional(), + expect: z + .object({ + result: z.string().optional(), + /** mode: snapshot, dir form: the EXACT document written to `//.yml`. */ + snapshot: SettingsSchema.optional(), + }) + .strict() + .optional(), }) .strict() // Both define the served settings.yml; setting both would silently favor one. @@ -369,6 +408,41 @@ const ScenarioSchema = z // A merge runs no engine: a fixpoint re-run would be a check against nothing. .refine((s) => s.inputs?.mode !== "merge" || s.expect.fixpoint === undefined, { message: "a mode: merge scenario cannot arm converges or apply_idempotent", + }) + // The snapshot destinations and their pins describe a mode: snapshot run and + // nothing else; anywhere else they would be dead configuration. + .refine( + (s) => + s.inputs?.mode === "snapshot" || + (s.inputs?.snapshot_file === undefined && s.inputs?.snapshot_dir === undefined), + { message: "snapshot_file and snapshot_dir only apply with inputs.mode: snapshot" }, + ) + .refine( + (s) => + s.inputs?.mode !== "snapshot" || + (s.inputs.snapshot_file === undefined) !== (s.inputs.snapshot_dir === undefined), + { + message: + "a mode: snapshot scenario sets exactly one of inputs.snapshot_file or inputs.snapshot_dir", + }, + ) + .refine( + (s) => + (s.expect.snapshot === undefined && s.expect.snapshot_converges === undefined) || + s.inputs?.snapshot_file !== undefined, + { + message: "expect.snapshot and expect.snapshot_converges only apply with inputs.snapshot_file", + }, + ) + .refine( + (s) => + s.inputs?.snapshot_dir !== undefined || + Object.values(s.repos ?? {}).every((repo) => repo.expect?.snapshot === undefined), + { message: "repos..expect.snapshot only applies with inputs.snapshot_dir" }, + ) + // A snapshot never runs the engine's apply path, so a fixpoint proof has no run to re-run. + .refine((s) => s.inputs?.mode !== "snapshot" || s.expect.fixpoint === undefined, { + message: "a mode: snapshot scenario cannot arm converges or apply_idempotent", }); export type MaskKey = z.infer; @@ -442,6 +516,7 @@ export function markerLabelFixtureMismatches(scenario: Scenario): string[] { for (const [slug, repo] of Object.entries(scenario.repos ?? {})) { roots.push([`repos.${slug}.settings`, repo.settings], [`repos.${slug}.expect`, repo.expect]); } + // Marker-label fixtures inside a pinned snapshot are declared data too. return roots.flatMap(([path, root]) => markerMismatchesIn(root, path)); } diff --git a/test/flows/inputs.test.ts b/test/flows/inputs.test.ts index e2e100503..38c511d48 100644 --- a/test/flows/inputs.test.ts +++ b/test/flows/inputs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { ok } from "neverthrow"; import { type ConfigEnv, + DEFAULT_DISCOVERY_FILTERS, INPUT_DECLS, type InputName, type Problem, @@ -9,6 +10,7 @@ import { type RunConfig, SECTION_KEYS, SectionSelection, + SNAPSHOT_REJECTED_INPUTS, } from "../../src/index.js"; /** Parse a step's inputs (unset ones read as empty, as the runner reports them) under `env`. */ @@ -45,12 +47,17 @@ function engineConfig( parsed: ReturnType, ): Extract { const config = parsed._unsafeUnwrap(); - if (config.kind === "merge") { + if (config.kind === "merge" || config.kind === "snapshot") { throw new Error(`expected an engine config, got: ${JSON.stringify(config)}`); } return config; } +/** A mode: snapshot run's inputs: the token, the mode, and `inputs` (one destination among them). */ +function snapshot(inputs: Partial>, env: ConfigEnv = {}) { + return parse({ token: "t", mode: "snapshot", ...inputs }, env); +} + describe("the declared defaults", () => { test("every unset input resolves to its declared default; the GITHUB_* context fills the rest", () => { expect( @@ -158,7 +165,7 @@ describe("the mode input", () => { input: "mode", value: "dry-run", noun: "mode", - allowed: ["apply", "check", "merge"], + allowed: ["apply", "check", "merge", "snapshot"], fallback: "apply", }); }); @@ -179,6 +186,28 @@ describe("the mode input", () => { }); }); + test.each([ + ["snapshot-file", { "snapshot-file": "snap.yml" }, ["snapshot-file"]], + ["snapshot-dir", { "snapshot-dir": "snapshots" }, ["snapshot-dir"]], + [ + "both snapshot-only inputs", + { "snapshot-file": "snap.yml", "snapshot-dir": "snapshots" }, + ["snapshot-file", "snapshot-dir"], + ], + ] as const)("%s outside snapshot mode is rejected", (_case, inputs, named) => { + expect(rejection(single({ mode: "check", ...inputs }))).toEqual({ + code: "input-snapshot-only", + inputs: named, + mode: "check", + }); + }); + + test("a merge-only input set beside a snapshot-only one is reported first: the first problem wins", () => { + expect( + rejection(single({ mode: "check", "merged-file": "out.yml", "snapshot-dir": "snapshots" })), + ).toEqual({ code: "input-merge-only", inputs: ["merged-file"], mode: "check" }); + }); + const SEPARATOR_CASES = [ ["a comma list", "a.yml,b.yml"], ["a newline list", "a.yml\nb.yml"], @@ -329,3 +358,181 @@ describe("mode: merge", () => { expect(JSON.stringify(parsed)).not.toContain("ghp_"); }); }); + +describe("mode: snapshot", () => { + const SHARED = { + kind: "snapshot", + token: "t", + apiVersion: "2022-11-28", + onMissingPermission: "fail", + sections: SectionSelection.ALL, + privateRepos: "redact", + selfSlug: "", + } as const; + + test("the file form targets the repository input and carries the selection and policies", () => { + expect( + snapshot({ + repository: "o/r", + "snapshot-file": "out/snapshot.yml", + sections: "labels,webhooks", + "on-missing-permission": "warn", + "private-repos": "show", + }), + ).toEqual( + ok({ + ...SHARED, + sections: SectionSelection.of({ only: ["labels", "webhooks"] })._unsafeUnwrap(), + onMissingPermission: "warn", + privateRepos: "show", + form: "file", + repo: { owner: "o", name: "r", slug: "o/r" }, + snapshotFile: "out/snapshot.yml", + }), + ); + }); + + test("the file form falls back to GITHUB_REPOSITORY, which is also the self slug", () => { + expect( + snapshot({ "snapshot-file": "snapshot.yml" }, { GITHUB_REPOSITORY: "self/repo" }), + ).toEqual( + ok({ + ...SHARED, + selfSlug: "self/repo", + form: "file", + repo: { owner: "self", name: "repo", slug: "self/repo" }, + snapshotFile: "snapshot.yml", + }), + ); + }); + + test("the dir form carries the multi-repo target inputs, discovery filters included", () => { + expect( + snapshot( + { + "snapshot-dir": "repos-snapshots", + repos: "*", + "repos-dir": "repos", + forks: "exclude", + topics: "Team-A", + }, + { GITHUB_REPOSITORY: "admin/fleet" }, + ), + ).toEqual( + ok({ + ...SHARED, + selfSlug: "admin/fleet", + form: "dir", + snapshotDir: "repos-snapshots", + reposInput: "*", + reposDir: "repos", + adminOwner: "admin", + discoveryFilters: { ...DEFAULT_DISCOVERY_FILTERS, forks: "exclude", topics: ["team-a"] }, + discoveryFiltersSet: ["forks", "topics"], + }), + ); + }); + + test.each([ + ["neither destination", {}, { code: "input-snapshot-destination-missing" }], + [ + "both destinations", + { "snapshot-file": "snap.yml", "snapshot-dir": "snapshots" }, + { code: "input-snapshot-destinations-both" }, + ], + [ + "snapshot-file beside repos", + { "snapshot-file": "snap.yml", repos: "o/a" }, + { code: "input-snapshot-file-with-multi" }, + ], + [ + "snapshot-file beside a discovery filter", + { "snapshot-file": "snap.yml", repository: "o/r", forks: "only" }, + { code: "discovery-filters-without-wildcard", filters: ["forks"], targets: "snapshot-file" }, + ], + [ + "snapshot-file without any repository to target", + { "snapshot-file": "snap.yml" }, + { code: "input-repository-not-slug", value: "" }, + ], + [ + "snapshot-dir beside repository", + { "snapshot-dir": "snapshots", repository: "o/r", repos: "o/a" }, + { code: "input-repository-with-snapshot-dir" }, + ], + [ + "snapshot-dir without targets", + { "snapshot-dir": "snapshots" }, + { code: "input-snapshot-dir-without-targets" }, + ], + [ + "an invalid discovery filter in the dir form", + { "snapshot-dir": "snapshots", repos: "*", archived: "maybe" }, + { + code: "input-unsupported-value", + input: "archived", + value: "maybe", + noun: "archived-repository policy", + allowed: ["skip", "include", "only"], + fallback: "skip", + }, + ], + [ + "no token anywhere", + { token: "", "snapshot-file": "snap.yml", repository: "o/r" }, + { code: "input-token-missing" }, + ], + ] as const)("%s is rejected", (_case, inputs, problem) => { + expect(rejection(snapshot(inputs))).toEqual(problem); + }); + + test("the rejected set is every declared input the snapshot does not read", () => { + expect(SNAPSHOT_REJECTED_INPUTS).toEqual([ + "settings-file", + "merged-file", + "required-sections", + "defaults-file", + "layering", + "private-report", + "report-public-key", + ]); + }); + + test.each([ + ["settings-file", { "settings-file": "other.yml" }, ["settings-file"]], + ["merged-file", { "merged-file": "out.yml" }, ["merged-file"]], + ["required-sections", { "required-sections": "labels" }, ["required-sections"]], + ["defaults-file", { "defaults-file": "defaults.yml" }, ["defaults-file"]], + ["layering", { layering: "replace" }, ["layering"]], + ["private-report", { "private-report": "issue" }, ["private-report"]], + ["report-public-key", { "report-public-key": "age1x" }, ["report-public-key"]], + [ + "several at once, every one named in declaration order", + { "defaults-file": "d.yml", "settings-file": "s.yml", layering: "merge" }, + ["settings-file", "defaults-file", "layering"], + ], + ] as const)( + "%s is rejected: a snapshot applies nothing, folds nothing, and reports nothing", + (_case, inputs, named) => { + expect( + rejection(snapshot({ "snapshot-file": "snap.yml", repository: "o/r", ...inputs })), + ).toEqual({ code: "input-rejected-in-snapshot", inputs: named }); + }, + ); + + test("every declared default passes: the runner supplies them whether or not the workflow set the input", () => { + const defaults = Object.fromEntries( + Object.entries(INPUT_DECLS) + .filter(([name]) => !["mode", "snapshot-file", "repository", "token"].includes(name)) + .map(([name, decl]) => [name, decl.default]), + ) as Partial>; + expect(snapshot({ "snapshot-file": "snap.yml", repository: "o/r", ...defaults })).toEqual( + ok({ + ...SHARED, + form: "file", + repo: { owner: "o", name: "r", slug: "o/r" }, + snapshotFile: "snap.yml", + }), + ); + }); +}); diff --git a/test/flows/library.test.ts b/test/flows/library.test.ts index a94f4eefd..ab20365c6 100644 --- a/test/flows/library.test.ts +++ b/test/flows/library.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { err, ok } from "neverthrow"; -import { stringify as stringifyYaml } from "yaml"; +import { parse as parseYamlDoc, stringify as stringifyYaml } from "yaml"; import { applyRepository, checkRepository, @@ -9,6 +9,9 @@ import { parseRepoSlug, renderMergedYaml, SectionSelection, + SNAPSHOT_SCHEMA_URL, + snapshotRepositories, + snapshotRepository, type ValidatedSettings, validateSettings, } from "../../src/index.js"; @@ -133,3 +136,94 @@ describe("renderMergedYaml", () => { expect(renderMergedYaml(settings)).toBe("repository:\n has_wiki: false\n"); }); }); + +describe("snapshotRepository and snapshotRepositories", () => { + const STAMPS = { created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }; + const routes = (slug: string) => ({ + [`GET /repos/${slug}/labels?per_page=100&page=1`]: { + data: [{ name: "bug", color: "d73a4a", description: "Something is broken" }], + }, + [`GET /repos/${slug}/actions/secrets?per_page=100&page=1`]: { + data: { total_count: 1, secrets: [{ name: "DEPLOY_TOKEN", ...STAMPS }] }, + }, + }); + const sections = SectionSelection.of({ only: ["labels", "actions_secrets"] })._unsafeUnwrap(); + const SECRET_NOTE = + "actions_secrets[DEPLOY_TOKEN]: value of DEPLOY_TOKEN is not readable; export it into the environment as SECRET_ACTIONS_DEPLOY_TOKEN before apply"; + + test("reads the allowed sections back, renders the file, and returns the lines when no Io is given", async () => { + const api = new MockApi(routes("o/r")); + const report = await snapshotRepository(api, repo, { sections }); + expect(api.mutations()).toEqual([]); + expect(report).toEqual({ + repo: "o/r", + result: "snapshot", + settings: branded({ + labels: { + _undeclared: "delete", + entries: [{ name: "bug", color: "d73a4a", description: "Something is broken" }], + }, + actions_secrets: { + _undeclared: "keep", + entries: [{ name: "DEPLOY_TOKEN", value: "$SECRET_ACTIONS_DEPLOY_TOKEN" }], + }, + }), + outcomes: [ + { key: "labels", status: "snapshot", detail: [] }, + { key: "actions_secrets", status: "snapshot", detail: [SECRET_NOTE] }, + ], + yaml: expect.stringMatching( + new RegExp( + `^# yaml-language-server: \\$schema=${SNAPSHOT_SCHEMA_URL.replaceAll(".", "\\.")}\n# Snapshot of o/r taken \\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\n# actions_secrets: ${SECRET_NOTE.replaceAll("[", "\\[").replaceAll("]", "\\]")}\nlabels:\n`, + ), + ), + log: [{ level: "notice", line: `actions_secrets: ${SECRET_NOTE}` }], + }); + if (report.yaml === undefined) { + throw new Error("a snapshot result carries its file"); + } + expect(stringifyYaml(parseYamlDoc(report.yaml))).toBe(stringifyYaml(report.settings)); + }); + + test("a denial under fail is a failed report with no yaml; a caller-supplied Io receives the lines and the log stays empty", async () => { + const api = new MockApi({}); + const collected = collectingIo(); + const report = await snapshotRepository(api, repo, { + sections: SectionSelection.of({ only: ["labels"] })._unsafeUnwrap(), + io: collected.io, + }); + expect(report).toEqual({ + repo: "o/r", + result: "failed", + outcomes: [ + { + key: "labels", + status: "failed", + detail: [expect.stringMatching(/^the token was denied GET \/repos\/o\/r\/labels/)], + }, + ], + log: [], + }); + expect(collected.lines).toEqual([ + { + level: "error", + line: expect.stringMatching(/^labels: not snapshotted - the token was denied GET/), + }, + ]); + }); + + test("snapshotRepositories reports each target in order; a failed target never stops the next", async () => { + const api = new MockApi(routes("o/b")); + const reports = await snapshotRepositories( + api, + [parseRepoSlug("o/a")._unsafeUnwrap(), parseRepoSlug("o/b")._unsafeUnwrap()], + { sections: SectionSelection.of({ only: ["labels"] })._unsafeUnwrap() }, + ); + expect( + reports.map((report) => [report.repo, report.result, report.yaml === undefined]), + ).toEqual([ + ["o/a", "failed", true], + ["o/b", "snapshot", false], + ]); + }); +}); diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts new file mode 100644 index 000000000..010597a2a --- /dev/null +++ b/test/flows/snapshot.test.ts @@ -0,0 +1,379 @@ +/** + * The mode: snapshot flow over a routed client: the file form writes one + * document and reports through the outputs and the summary, the dir form + * writes one file per resolved target under the repos-dir layout, a redacted + * target's values reach the file and nothing else, and every failure class + * (denial, unwritable path, a name that would leave the directory, no + * targets) ends as the documented result. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { parseRepoSlug } from "../../src/discovery/targets.js"; +import { SectionSelection } from "../../src/engine/section-selection.js"; +import { failRun } from "../../src/flows/deliver.js"; +import { + concludeSnapshot, + runSnapshot, + SNAPSHOT_SCHEMA_URL, + type SnapshotConfig, +} from "../../src/flows/snapshot.js"; +import { collectingIo, type Io } from "../../src/io.js"; +import type { SectionKey } from "../../src/schema.js"; +import { MockApi } from "../mock-api.js"; + +const repo = parseRepoSlug("o/r")._unsafeUnwrap(); + +/** Run a snapshot the way the action does: the finished run concludes, a problem fails the run. */ +async function run(api: MockApi, cfg: SnapshotConfig, io: Io): Promise { + return (await runSnapshot(api, cfg, io)).match( + (finished) => concludeSnapshot(io, finished), + (problem) => failRun(io, problem), + ); +} + +/** The selection that processes only `keys`. */ +function only(...keys: SectionKey[]): SectionSelection { + return SectionSelection.of({ only: keys })._unsafeUnwrap(); +} + +const BUG = { name: "bug", color: "d73a4a", description: "Something is broken" }; +const DOCS = { name: "docs", color: "0075ca", description: "" }; +const doc = (...labels: Array) => ({ + labels: { _undeclared: "delete", entries: labels }, +}); +const labelsRoute = (slug: string, labels: Array) => ({ + [`GET /repos/${slug}/labels?per_page=100&page=1`]: { data: labels }, +}); + +/** The header a written snapshot starts with: the schema pin, then the dated repository line. */ +const HEADER = (slug: string) => + new RegExp( + `^# yaml-language-server: \\$schema=${SNAPSHOT_SCHEMA_URL.replaceAll(".", "\\.")}\\n# Snapshot of ${slug} taken \\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\n`, + ); + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "snapshot-flow-")); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +type FileConfig = Extract; +type DirConfig = Extract; + +const fileCfg = (overrides: Partial = {}): FileConfig => + ({ + form: "file", + repo, + snapshotFile: join(dir, "out", "snapshot.yml"), + onMissingPermission: "fail", + sections: only("labels"), + privateRepos: "show", + selfSlug: "o/r", + ...overrides, + }) as FileConfig; + +const dirCfg = (overrides: Partial = {}): DirConfig => + ({ + form: "dir", + snapshotDir: join(dir, "snapshots"), + reposInput: "o/a,o/b", + reposDir: "", + adminOwner: "admin", + discoveryFilters: { + visibility: "all", + archived: "skip", + forks: "include", + affiliation: ["owner"], + topics: [], + exclude: [], + }, + discoveryFiltersSet: [], + onMissingPermission: "fail", + sections: only("labels"), + privateRepos: "redact", + selfSlug: "admin/fleet", + ...overrides, + }) as DirConfig; + +describe("runSnapshot, file form", () => { + test("writes the document under its header and reports through the outputs, the log, and the summary", async () => { + const api = new MockApi(labelsRoute("o/r", [BUG, DOCS])); + const cfg = fileCfg(); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const written = readFileSync(cfg.snapshotFile, "utf8"); + expect(written).toMatch(HEADER("o/r")); + expect(parseYaml(written)).toEqual(doc(BUG, DOCS)); + expect(api.mutations()).toEqual([]); + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "snapshot" }); + expect(collected.lines).toEqual([ + { line: `snapshot written to ${cfg.snapshotFile}` }, + { line: "result: snapshot" }, + ]); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot)", + "", + `:white_check_mark: snapshot - written to ${cfg.snapshotFile}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | - |", + ].join("\n"), + ]); + }); + + test("a denied section is skipped under warn: partial, the file omits it, the header and the outputs say so", async () => { + // No variables route: the read answers 404, the fine-grained denial. + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg({ + onMissingPermission: "warn", + sections: only("labels", "actions_variables", "check_suite_preferences"), + }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const written = readFileSync(cfg.snapshotFile, "utf8"); + expect(parseYaml(written)).toEqual(doc(BUG)); + expect(written).toContain( + "# check_suite_preferences: GitHub exposes no read endpoint for this section, so there is nothing to snapshot; apply re-asserts the declared value on every run\n", + ); + expect(written).toMatch( + /^# actions_variables: the token was denied GET \/repos\/o\/r\/actions\/variables/m, + ); + expect(collected.outputs).toEqual({ + "skipped-sections": "actions_variables", + result: "partial", + }); + expect(collected.lines.map((entry) => `${entry.level ?? "log"}: ${entry.line}`)).toEqual([ + expect.stringMatching(/^warning: actions_variables: skipped - the token was denied GET/), + "notice: not snapshotted: check_suite_preferences - snapshot does not read these sections back, so the file omits them (the header says why); declare them by hand if they should be managed", + `log: snapshot written to ${cfg.snapshotFile}`, + "log: result: partial", + ]); + expect(collected.summary[0]).toContain(":warning: partial - written to"); + expect(collected.summary[0]).toContain("| actions_variables | :fast_forward: skipped |"); + expect(collected.summary[0]).toContain( + "| check_suite_preferences | :fast_forward: unsupported |", + ); + }); + + test("a denied section under fail fails the run and writes no file", async () => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg({ sections: only("labels", "actions_variables") }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(cfg.snapshotFile)).toBe(false); + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "failed" }); + expect(collected.lines).toEqual([ + { + level: "error", + line: expect.stringMatching( + /^actions_variables: not snapshotted - the token was denied GET/, + ), + }, + { line: "result: failed" }, + ]); + expect(collected.summary[0]).toContain( + ":x: failed - the snapshot failed, so no file was written", + ); + }); + + test("an unwritable snapshot-file fails the run naming the input", async () => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + writeFileSync(join(dir, "blocker"), ""); + const cfg = fileCfg({ snapshotFile: join(dir, "blocker", "snapshot.yml") }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(collected.outputs.result).toBe("failed"); + expect(collected.lines[0]).toEqual({ + level: "error", + line: expect.stringMatching( + new RegExp( + `^cannot write the snapshot to ${cfg.snapshotFile.replaceAll(".", "\\.")}: .*(EEXIST|ENOTDIR).*\\. Check that the "snapshot-file" input names a writable path$`, + ), + ), + }); + }); +}); + +describe("runSnapshot refuses a destination that would overwrite an authored file", () => { + test.each([ + [ + "snapshot-file naming the settings file apply reads", + () => fileCfg({ snapshotFile: "./.github/settings.yml" }), + 'the "snapshot-file" input "./.github/settings.yml" is the settings file apply and check read (.github/settings.yml): the snapshot would overwrite the document you author. Write it to another path and copy it over deliberately', + ], + [ + "snapshot-dir equal to the repos-dir", + () => dirCfg({ snapshotDir: "./repos", reposDir: "repos" }), + 'the "snapshot-dir" input "./repos" is, contains, or sits inside the "repos-dir" "repos": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + ], + [ + "snapshot-dir above the repos-dir, where a bare .yml would be overwritten", + () => dirCfg({ snapshotDir: "central", reposDir: "central/acme" }), + 'the "snapshot-dir" input "central" is, contains, or sits inside the "repos-dir" "central/acme": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + ], + [ + "snapshot-dir below the repos-dir under a name starting with two dots, which is still below it", + () => dirCfg({ snapshotDir: "central/..snapshots", reposDir: "central" }), + 'the "snapshot-dir" input "central/..snapshots" is, contains, or sits inside the "repos-dir" "central": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + ], + [ + "snapshot-dir below the repos-dir, where the next run would read the snapshots as central files", + () => dirCfg({ snapshotDir: "central/snapshots", reposDir: "central" }), + 'the "snapshot-dir" input "central/snapshots" is, contains, or sits inside the "repos-dir" "central": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + ], + ])("%s fails before any API call or write", async (_case, cfg, message) => { + const api = new MockApi({}); + const collected = collectingIo(); + expect(await run(api, cfg(), collected.io)).toBe(1); + expect(api.calls).toEqual([]); + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "failed" }); + expect(collected.lines).toEqual([ + { level: "error", line: message }, + { line: "result: failed" }, + ]); + }); +}); + +describe("runSnapshot, dir form", () => { + test("writes one /.yml per resolved target and publishes the per-target rollup", async () => { + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/b": { data: { private: false } }, + ...labelsRoute("o/a", [BUG]), + ...labelsRoute("o/b", [DOCS]), + }); + const cfg = dirCfg(); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const fileA = join(cfg.snapshotDir, "o", "a.yml"); + const fileB = join(cfg.snapshotDir, "o", "b.yml"); + expect(readFileSync(fileA, "utf8")).toMatch(HEADER("o/a")); + expect(parseYaml(readFileSync(fileA, "utf8"))).toEqual(doc(BUG)); + expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); + expect(api.mutations()).toEqual([]); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "snapshot", + "repos-result": JSON.stringify({ + "o/a": { result: "snapshot", source: "remote", skippedSections: [] }, + "o/b": { result: "snapshot", source: "remote", skippedSections: [] }, + }), + }); + expect(collected.lines).toEqual([ + { line: `o/a: snapshot written to ${fileA}` }, + { line: `o/b: snapshot written to ${fileB}` }, + { line: "result: snapshot" }, + ]); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot, 2 repositories)", + "", + `Snapshots written under ${cfg.snapshotDir}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + `| o/a | remote | :white_check_mark: snapshot | ${fileA} |`, + `| o/b | remote | :white_check_mark: snapshot | ${fileB} |`, + "", + "### o/a (snapshot)", + "", + `written to ${fileA}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | - |", + "", + "### o/b (snapshot)", + "", + `written to ${fileB}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | - |", + ].join("\n"), + ]); + }); + + test("a redacted target's values reach its file and nothing else: the slug is masked, the public view shows the placeholder", async () => { + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/p": { data: { private: true, visibility: "private" } }, + ...labelsRoute("o/a", [BUG]), + ...labelsRoute("o/p", [{ name: "secret-project", color: "000000", description: "hush" }]), + }); + const cfg = dirCfg({ reposInput: "o/a,o/p" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + expect(parseYaml(readFileSync(join(cfg.snapshotDir, "o", "p.yml"), "utf8"))).toEqual( + doc({ name: "secret-project", color: "000000", description: "hush" }), + ); + expect([...collected.io.masked()]).toEqual(["o/p"]); + const publicText = [ + ...collected.lines.map((entry) => entry.line), + ...collected.summary, + ...Object.values(collected.outputs), + ].join("\n"); + for (const needle of ["o/p", "p.yml", "secret-project", "hush"]) { + expect(publicText, `"${needle}" reached a public surface`).not.toContain(needle); + } + expect(collected.outputs["repos-result"]).toBe( + JSON.stringify({ + "o/a": { result: "snapshot", source: "remote", skippedSections: [] }, + "private repository #1": { result: "snapshot", source: "remote", skippedSections: [] }, + }), + ); + expect(collected.summary[0]).toContain( + "| private repository #1 | remote | :white_check_mark: snapshot | hidden (private repository) |", + ); + expect(collected.summary[0]).toContain( + "### private repository #1 (snapshot)\n\ndetails hidden: the repository is private or internal.", + ); + expect(collected.summary[0]).toContain( + "| labels | :white_check_mark: snapshot | hidden (private repository) |", + ); + }); + + test("a name that would leave the directory fails its target alone; the rest of the fleet is written", async () => { + const api = new MockApi({ ...labelsRoute("o/a", [BUG]) }); + const cfg = dirCfg({ reposInput: "../escape,o/a", privateRepos: "show" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(join(dir, "escape.yml"))).toBe(false); + expect(existsSync(join(cfg.snapshotDir, "o", "a.yml"))).toBe(true); + expect(collected.outputs.result).toBe("failed"); + expect(collected.lines[0]).toEqual({ + level: "error", + line: `../escape: the repository name "../escape" is not a GitHub owner/name (a "." or ".." segment), so it has no file under ${cfg.snapshotDir}`, + }); + expect(JSON.parse(collected.outputs["repos-result"] ?? "")).toEqual({ + "../escape": { result: "failed", source: "remote", skippedSections: [] }, + "o/a": { result: "snapshot", source: "remote", skippedSections: [] }, + }); + }); + + test("a fleet that resolves to no targets is fatal before any file is written", async () => { + const empty = join(dir, "repos"); + mkdirSync(empty); + const api = new MockApi({}); + const cfg = dirCfg({ reposInput: "", reposDir: empty }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(api.calls).toEqual([]); + expect(existsSync(join(dir, "snapshots"))).toBe(false); + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "failed" }); + expect(collected.lines[0]).toEqual({ + level: "error", + line: expect.stringMatching( + /^multi-repo mode found no targets: repos-dir yielded no settings files/, + ), + }); + }); +}); diff --git a/test/library/public-surface.test.ts b/test/library/public-surface.test.ts index b117c4dae..7fcae4aed 100644 --- a/test/library/public-surface.test.ts +++ b/test/library/public-surface.test.ts @@ -45,6 +45,7 @@ describe("the public entry", () => { "FILTER_INPUTS", "FORKS_FILTERS", "FinishedMerge", + "FinishedSnapshot", "GithubApi", "GithubClient", "GraphqlOp", @@ -86,6 +87,7 @@ describe("the public entry", () => { "REPO_RESULTS", "RERUN_ADVICE", "RemoteTarget", + "RenderableSnapshot", "RepoRef", "RepoResult", "RepoRunOptions", @@ -93,11 +95,16 @@ describe("the public entry", () => { "RepoRunResult", "RepoVisibility", "ReportInput", + "ResolvedTargets", "Route", "RunConfig", "RunFlowConfig", "SECTIONS", "SECTION_KEYS", + "SNAPSHOT_INPUTS", + "SNAPSHOT_REJECTED_INPUTS", + "SNAPSHOT_RESULTS", + "SNAPSHOT_SCHEMA_URL", "SectionKey", "SectionMeta", "SectionModule", @@ -109,9 +116,16 @@ describe("the public entry", () => { "SettingsProblem", "SingleConfig", "SingleOutcome", + "SnapshotConfig", + "SnapshotLibraryOptions", + "SnapshotReport", + "SnapshotResult", + "SnapshotRunResult", + "SnapshotTargetView", "TaggedEndpoint", "Target", "TargetOutcome", + "TargetsConfig", "Tolerance", "TopLevelShape", "TraceIo", @@ -132,6 +146,7 @@ describe("the public entry", () => { "composeReport", "concludeMerge", "concludeRun", + "concludeSnapshot", "createVisibilityResolver", "dedupeTargets", "deliverArtifactReport", @@ -166,16 +181,21 @@ describe("the public entry", () => { "readSettingsFile", "redactRanges", "renderMergedYaml", + "renderSnapshotYaml", "resolveCentralTargets", + "resolveTargets", "runForRepo", "runMerge", "runMulti", "runSingle", + "runSnapshot", "sectionGrant", "sectionModule", "sectionOperations", "silentIo", "skippedSectionKeys", + "snapshotRepositories", + "snapshotRepository", "stripNulls", "toPublicView", "validateSettings", diff --git a/test/problem.test.ts b/test/problem.test.ts index 8de5dd811..a7345d2c4 100644 --- a/test/problem.test.ts +++ b/test/problem.test.ts @@ -91,6 +91,37 @@ const SPECIMENS = { { code: "input-merge-only", inputs: ["merged-file", "layering"], mode: "check" }, 'the "merged-file", "layering" input(s) only apply to mode: merge, but this run is in check mode, so they would never be used. Remove the input(s), or set mode: merge to fold settings files', ], + "input-snapshot-only": [ + { code: "input-snapshot-only", inputs: ["snapshot-file"], mode: "check" }, + 'the "snapshot-file" input(s) only apply to mode: snapshot, but this run is in check mode, so it would never be used. Remove the input(s), or set mode: snapshot to write the live settings to a file', + ], + "input-rejected-in-snapshot": [ + { code: "input-rejected-in-snapshot", inputs: ["settings-file", "layering"] }, + 'the "settings-file", "layering" input(s) do not apply to mode: snapshot, which only reads the ' + + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + + "the apply or check step that runs the snapshot", + ], + "input-snapshot-destination-missing": [ + { code: "input-snapshot-destination-missing" }, + 'mode: snapshot needs exactly one of the "snapshot-file" input (one repository\'s settings written to that file) or the "snapshot-dir" input (one /.yml per repos or repos-dir target under that directory). Set one of them', + ], + "input-snapshot-destinations-both": [ + { code: "input-snapshot-destinations-both" }, + 'the "snapshot-file" and "snapshot-dir" inputs are both set, but a snapshot run writes one form: a single repository to snapshot-file, or one /.yml per multi-repo target under snapshot-dir. Remove one of them', + ], + "input-snapshot-file-with-multi": [ + { code: "input-snapshot-file-with-multi" }, + 'the "snapshot-file" input writes one repository\'s snapshot, but "repos" or "repos-dir" names multi-repo targets. Set "snapshot-dir" to write one file per target, or remove the multi-repo inputs and name the repository with "repository"', + ], + "input-repository-with-snapshot-dir": [ + { code: "input-repository-with-snapshot-dir" }, + 'the "repository" input cannot be combined with "snapshot-dir", which writes one file per "repos" or "repos-dir" target. Remove "repository", or set "snapshot-file" to snapshot one repository', + ], + "input-snapshot-dir-without-targets": [ + { code: "input-snapshot-dir-without-targets" }, + 'the "snapshot-dir" input needs multi-repo targets: set "repos" (an owner/name list, or "*" to discover) or "repos-dir". To snapshot one repository, set "snapshot-file" instead', + ], "input-token-missing": [ { code: "input-token-missing" }, 'cannot call the GitHub API: no token was provided. Set the "token" input on the action step (or export GITHUB_TOKEN)', @@ -222,6 +253,21 @@ const SPECIMENS = { { code: "merged-file-unwritable", path: "out/merged.yml", reason: "EACCES" }, 'cannot write the merged document to out/merged.yml: EACCES. Check that the "merged-file" input names a writable path', ], + "snapshot-file-is-settings-file": [ + { + code: "snapshot-file-is-settings-file", + snapshotFile: "./.github/settings.yml", + settingsFile: ".github/settings.yml", + }, + 'the "snapshot-file" input "./.github/settings.yml" is the settings file apply and check read (.github/settings.yml): the snapshot would overwrite the document you author. Write it to another path and copy it over deliberately', + ], + "snapshot-dir-overlaps-repos-dir": [ + { code: "snapshot-dir-overlaps-repos-dir", snapshotDir: "central", reposDir: "central/acme" }, + 'the "snapshot-dir" input "central" is, contains, or sits inside the "repos-dir" ' + + '"central/acme": the snapshots are written in the repos-dir layout, so they would overwrite ' + + "the central settings files or be read back as central files. Write them to a directory " + + "outside the repos-dir and copy them over deliberately", + ], "no-targets": [ { code: "no-targets", filteredOut: 2 }, 'multi-repo mode found no targets: repos: "*" discovery found 2 repositories, but the discovery filters removed all of them (see the notices above). Relax the filter inputs, or add per-repo files to the repos-dir', diff --git a/test/schema-corpus.test.ts b/test/schema-corpus.test.ts index ab6b99306..8867dfe37 100644 --- a/test/schema-corpus.test.ts +++ b/test/schema-corpus.test.ts @@ -53,13 +53,20 @@ function scenarioDocs(): CorpusDoc[] { const name = basename(file); push(`${name} settings`, scenario.settings); push(`${name} defaults_file`, scenario.defaults_file); - const repos = scenario.repos as Record | undefined; + // A pinned snapshot document is what mode: snapshot writes for a later + // apply to read, so both validators must accept it like any settings file. + const expected = scenario.expect as { snapshot?: unknown } | undefined; + push(`${name} expect.snapshot`, expected?.snapshot); + const repos = scenario.repos as + | Record + | undefined; for (const [repo, entry] of Object.entries(repos ?? {})) { push(`${name} ${repo} settings`, entry?.settings); + push(`${name} ${repo} expect.snapshot`, entry?.expect?.snapshot); } } // The corpus size is pinned exactly so a loader that silently drops a root, a file, or a document kind cannot pass. - expect(docs.length).toBe(262); + expect(docs.length).toBe(305); return docs; } From d45f3638f5157f83a7b610d2603889a10e0f0d57 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:52:31 -0400 Subject: [PATCH 2/9] style(snapshot): wrap the mode's long message literals under the fleet line cap The fleet's check-file-size gate now fails a line over 256 characters. Every literal this change introduced is split at clause boundaries; the strings are byte-identical (the exact-match tests pin them). The over-long lines the gate flags elsewhere predate this change. --- src/action/io.ts | 4 +++- test/action/run.test.ts | 5 ++++- test/flows/snapshot.test.ts | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/action/io.ts b/src/action/io.ts index 9f2f2d91a..8475b0476 100644 --- a/src/action/io.ts +++ b/src/action/io.ts @@ -14,7 +14,9 @@ export const OUTPUT_DECLS = { }, "repos-result": { description: - 'Multi-repo mode (and the snapshot-dir form of mode: snapshot) only: JSON map of owner/name to {result, source, skippedSections}. A redacted private target is keyed by its "private repository #N" placeholder instead of its slug. Empty in single-repo mode.', + "Multi-repo mode (and the snapshot-dir form of mode: snapshot) only: JSON map of owner/name " + + "to {result, source, skippedSections}. A redacted private target is keyed by its " + + '"private repository #N" placeholder instead of its slug. Empty in single-repo mode.', }, } as const satisfies Record; diff --git a/test/action/run.test.ts b/test/action/run.test.ts index e4240bc41..42629e52c 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -874,7 +874,10 @@ describe("run in mode: snapshot", () => { expect(api.calls).toEqual([]); expect(existsSync(snapshotFile)).toBe(false); expect(captured).toEqual([ - 'error: the "settings-file" input(s) do not apply to mode: snapshot, which only reads the target repositories\' live settings into snapshot-file or snapshot-dir: it applies no document, folds no layers, and delivers no report. Remove the input(s), or move them to the apply or check step that runs the snapshot', + 'error: the "settings-file" input(s) do not apply to mode: snapshot, which only reads the ' + + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + + "the apply or check step that runs the snapshot", "result: failed", ]); }); diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index 010597a2a..bc0ef2ea6 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -202,6 +202,16 @@ describe("runSnapshot, file form", () => { }); }); +/** The refusal a snapshot-dir gets when it is not disjoint from the repos-dir. */ +function disjointRefusal(snapshotDir: string, reposDir: string): string { + return ( + `the "snapshot-dir" input "${snapshotDir}" is, contains, or sits inside the "repos-dir" ` + + `"${reposDir}": the snapshots are written in the repos-dir layout, so they would overwrite ` + + "the central settings files or be read back as central files. Write them to a directory " + + "outside the repos-dir and copy them over deliberately" + ); +} + describe("runSnapshot refuses a destination that would overwrite an authored file", () => { test.each([ [ @@ -212,22 +222,22 @@ describe("runSnapshot refuses a destination that would overwrite an authored fil [ "snapshot-dir equal to the repos-dir", () => dirCfg({ snapshotDir: "./repos", reposDir: "repos" }), - 'the "snapshot-dir" input "./repos" is, contains, or sits inside the "repos-dir" "repos": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + disjointRefusal("./repos", "repos"), ], [ "snapshot-dir above the repos-dir, where a bare .yml would be overwritten", () => dirCfg({ snapshotDir: "central", reposDir: "central/acme" }), - 'the "snapshot-dir" input "central" is, contains, or sits inside the "repos-dir" "central/acme": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + disjointRefusal("central", "central/acme"), ], [ "snapshot-dir below the repos-dir under a name starting with two dots, which is still below it", () => dirCfg({ snapshotDir: "central/..snapshots", reposDir: "central" }), - 'the "snapshot-dir" input "central/..snapshots" is, contains, or sits inside the "repos-dir" "central": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + disjointRefusal("central/..snapshots", "central"), ], [ "snapshot-dir below the repos-dir, where the next run would read the snapshots as central files", () => dirCfg({ snapshotDir: "central/snapshots", reposDir: "central" }), - 'the "snapshot-dir" input "central/snapshots" is, contains, or sits inside the "repos-dir" "central": the snapshots are written in the repos-dir layout, so they would overwrite the central settings files or be read back as central files. Write them to a directory outside the repos-dir and copy them over deliberately', + disjointRefusal("central/snapshots", "central"), ], ])("%s fails before any API call or write", async (_case, cfg, message) => { const api = new MockApi({}); From ee398ec9d657aece7510829daeb35e8a24a7f8af Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:52:31 -0400 Subject: [PATCH 3/9] test(e2e): the snapshot round-trip check carries every input the scenario set The check run that proves a written snapshot round-trips built its inputs from an allowlist of three, so a target-selecting input added to the scenario schema later would have been dropped and the check would have run against the wrong repository. The inputs are now derived by exclusion: everything carries over except the mode and the two snapshot destinations check mode rejects. A unit test pins the derivation. --- test/e2e/runner.test.ts | 31 +++++++++++++++++++++++++++++++ test/e2e/runner.ts | 24 ++++++++++++------------ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/test/e2e/runner.test.ts b/test/e2e/runner.test.ts index 0573af956..b88afa226 100644 --- a/test/e2e/runner.test.ts +++ b/test/e2e/runner.test.ts @@ -20,11 +20,42 @@ import { requestLogFailures, type ScenarioReport, setReplay, + snapshotCheckInputs, stripDebugLines, stripMaskLines, } from "./runner.js"; import type { Scenario } from "./schema.js"; +describe("snapshotCheckInputs (the round-trip check's inputs)", () => { + test("carries every input the snapshot set, drops both destinations, and switches the mode", () => { + // private_report at its default is legal in snapshot mode and outside the + // three inputs the check once allowlisted: only derivation by exclusion keeps it. + expect( + snapshotCheckInputs({ + mode: "snapshot", + snapshot_file: "snapshot.yml", + snapshot_dir: "snapshots", + sections: "labels,webhooks", + on_missing_permission: "warn", + private_repos: "show", + private_report: "none", + }), + ).toEqual({ + mode: "check", + sections: "labels,webhooks", + on_missing_permission: "warn", + private_repos: "show", + private_report: "none", + }); + }); + + test("a scenario with only the destination yields a bare check run", () => { + expect(snapshotCheckInputs({ mode: "snapshot", snapshot_file: "snapshot.yml" })).toEqual({ + mode: "check", + }); + }); +}); + describe("bundle build parity (harness vs production)", () => { test("the declared build:bundle script matches what the harness builds", () => { // A unit test on purpose: a package.json-only diff selects no sections and skips the e2e smoke diff --git a/test/e2e/runner.ts b/test/e2e/runner.ts index cf7349630..0ec56774e 100644 --- a/test/e2e/runner.ts +++ b/test/e2e/runner.ts @@ -286,6 +286,17 @@ function pinnedDirSnapshots( return pinned; } +/** + * The check run that proves a snapshot round-trips takes every input the scenario set, minus the two + * destinations check mode rejects; derived by exclusion so a target-selecting input added later still rides along. + */ +export function snapshotCheckInputs( + inputs: NonNullable, +): NonNullable { + const { snapshot_file: _file, snapshot_dir: _dir, ...carried } = inputs; + return { ...carried, mode: "check" }; +} + /** Built from scratch: only PATH and HOME are taken from process.env. */ function childEnv(scenario: Scenario, dir: string, apiUrl: string): NodeJS.ProcessEnv { const inputs = scenario.inputs ?? {}; @@ -708,19 +719,8 @@ export async function runScenario( const writesBefore = handle.requests.length; handle.enterCheckMode(); copyFileSync(join(dir, snapshotFile), join(dir, "settings.yml")); - const inputs = scenario.inputs ?? {}; const check = await invoke( - { - ...scenario, - inputs: { - mode: "check", - ...(inputs.sections === undefined ? {} : { sections: inputs.sections }), - ...(inputs.on_missing_permission === undefined - ? {} - : { on_missing_permission: inputs.on_missing_permission }), - ...(inputs.private_repos === undefined ? {} : { private_repos: inputs.private_repos }), - }, - }, + { ...scenario, inputs: snapshotCheckInputs(scenario.inputs ?? {}) }, dir, handle.url, ); From 6074da97a7cb23b7f7760d2a3372b5f5966217d5 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:52:31 -0400 Subject: [PATCH 4/9] test(snapshot): compare the written header by string equality, never as a pattern built from the schema URL The flow and library tests built a RegExp from SNAPSHOT_SCHEMA_URL (dots escaped by hand), which CodeQL reads as a hostname pattern that can match more hosts than intended. The header is now split into lines and the schema pin compared whole; a look-alike host with one dot replaced is pinned as rejected. The unwritable-path assertion drops its path-derived RegExp the same way. --- test/flows/library.test.ts | 15 ++++++---- test/flows/snapshot.test.ts | 59 +++++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/test/flows/library.test.ts b/test/flows/library.test.ts index ab20365c6..14355bd39 100644 --- a/test/flows/library.test.ts +++ b/test/flows/library.test.ts @@ -172,16 +172,19 @@ describe("snapshotRepository and snapshotRepositories", () => { { key: "labels", status: "snapshot", detail: [] }, { key: "actions_secrets", status: "snapshot", detail: [SECRET_NOTE] }, ], - yaml: expect.stringMatching( - new RegExp( - `^# yaml-language-server: \\$schema=${SNAPSHOT_SCHEMA_URL.replaceAll(".", "\\.")}\n# Snapshot of o/r taken \\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\n# actions_secrets: ${SECRET_NOTE.replaceAll("[", "\\[").replaceAll("]", "\\]")}\nlabels:\n`, - ), - ), - log: [{ level: "notice", line: `actions_secrets: ${SECRET_NOTE}` }], + yaml: expect.any(String), + log: [{ level: "notice", line: SECRET_NOTE }], }); if (report.yaml === undefined) { throw new Error("a snapshot result carries its file"); } + // The header, line by line and by equality: the pin is a URL, never a pattern. + const [pin, dated, note, first] = report.yaml.split("\n"); + expect(pin).toBe(`# yaml-language-server: $schema=${SNAPSHOT_SCHEMA_URL}`); + expect(dated).toMatch( + /^# Snapshot of o\/r taken \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, + ); + expect([note, first]).toEqual([`# ${SECRET_NOTE}`, "labels:"]); expect(stringifyYaml(parseYamlDoc(report.yaml))).toBe(stringifyYaml(report.settings)); }); diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index bc0ef2ea6..c11f8354e 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -49,11 +49,22 @@ const labelsRoute = (slug: string, labels: Array) => ({ [`GET /repos/${slug}/labels?per_page=100&page=1`]: { data: labels }, }); -/** The header a written snapshot starts with: the schema pin, then the dated repository line. */ -const HEADER = (slug: string) => - new RegExp( - `^# yaml-language-server: \\$schema=${SNAPSHOT_SCHEMA_URL.replaceAll(".", "\\.")}\\n# Snapshot of ${slug} taken \\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\n`, - ); +/** An ISO-8601 UTC instant, the form the header dates the snapshot in. */ +const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; + +/** + * Whether `written` opens with the snapshot header for `slug`: the schema pin + * compared as a whole string (never as a pattern, so a look-alike host cannot + * pass), then the dated repository line. + */ +export function hasSnapshotHeader(written: string, slug: string): boolean { + const [pin, dated] = written.split("\n"); + if (pin !== `# yaml-language-server: $schema=${SNAPSHOT_SCHEMA_URL}`) { + return false; + } + const prefix = `# Snapshot of ${slug} taken `; + return dated?.startsWith(prefix) === true && ISO_INSTANT.test(dated.slice(prefix.length)); +} let dir: string; beforeEach(() => { @@ -101,6 +112,24 @@ const dirCfg = (overrides: Partial = {}): DirConfig => ...overrides, }) as DirConfig; +describe("hasSnapshotHeader", () => { + const header = (url: string, slug = "o/r", instant = "2026-09-12T01:00:06.503Z") => + `# yaml-language-server: $schema=${url}\n# Snapshot of ${slug} taken ${instant}\nlabels: []\n`; + + test("accepts the exact pin and a dated line; rejects a look-alike host, another slug, and a non-instant", () => { + expect(hasSnapshotHeader(header(SNAPSHOT_SCHEMA_URL), "o/r")).toBe(true); + // The URL's dots are literal: a host with one dot swapped for another character is not the pin. + expect( + hasSnapshotHeader( + header(SNAPSHOT_SCHEMA_URL.replace("githubusercontent.com", "githubusercontentXcom")), + "o/r", + ), + ).toBe(false); + expect(hasSnapshotHeader(header(SNAPSHOT_SCHEMA_URL, "o/other"), "o/r")).toBe(false); + expect(hasSnapshotHeader(header(SNAPSHOT_SCHEMA_URL, "o/r", "yesterday"), "o/r")).toBe(false); + }); +}); + describe("runSnapshot, file form", () => { test("writes the document under its header and reports through the outputs, the log, and the summary", async () => { const api = new MockApi(labelsRoute("o/r", [BUG, DOCS])); @@ -108,7 +137,7 @@ describe("runSnapshot, file form", () => { const collected = collectingIo(); expect(await run(api, cfg, collected.io)).toBe(0); const written = readFileSync(cfg.snapshotFile, "utf8"); - expect(written).toMatch(HEADER("o/r")); + expect(hasSnapshotHeader(written, "o/r")).toBe(true); expect(parseYaml(written)).toEqual(doc(BUG, DOCS)); expect(api.mutations()).toEqual([]); expect(collected.outputs).toEqual({ "skipped-sections": "", result: "snapshot" }); @@ -191,14 +220,14 @@ describe("runSnapshot, file form", () => { const collected = collectingIo(); expect(await run(api, cfg, collected.io)).toBe(1); expect(collected.outputs.result).toBe("failed"); - expect(collected.lines[0]).toEqual({ - level: "error", - line: expect.stringMatching( - new RegExp( - `^cannot write the snapshot to ${cfg.snapshotFile.replaceAll(".", "\\.")}: .*(EEXIST|ENOTDIR).*\\. Check that the "snapshot-file" input names a writable path$`, - ), - ), - }); + const [first] = collected.lines; + expect(first?.level).toBe("error"); + // The path is compared as text, never as a pattern; the OS error code sits between. + expect(first?.line.startsWith(`cannot write the snapshot to ${cfg.snapshotFile}: `)).toBe(true); + expect(first?.line).toMatch(/E(EXIST|NOTDIR)/); + expect( + first?.line.endsWith('. Check that the "snapshot-file" input names a writable path'), + ).toBe(true); }); }); @@ -265,7 +294,7 @@ describe("runSnapshot, dir form", () => { expect(await run(api, cfg, collected.io)).toBe(0); const fileA = join(cfg.snapshotDir, "o", "a.yml"); const fileB = join(cfg.snapshotDir, "o", "b.yml"); - expect(readFileSync(fileA, "utf8")).toMatch(HEADER("o/a")); + expect(hasSnapshotHeader(readFileSync(fileA, "utf8"), "o/a")).toBe(true); expect(parseYaml(readFileSync(fileA, "utf8"))).toEqual(doc(BUG)); expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); expect(api.mutations()).toEqual([]); From 5bdbfb459a4fccc36f0bd2c09a189290b5fd3faf Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:50:13 -0400 Subject: [PATCH 5/9] fix(snapshot): gate folds - rejection wording, exact result enumeration, generated outputs list, artifact note The snapshot rejection's fix names the apply, check, or merge step an input belongs to (merged-file and layering are refused by apply and check too). The action.yml result-output test asserts the enumerated values as an exact set, so a value dropped from the enumeration while still named in prose fails. The outputs-list generated region on the inputs reference now carries the snapshot results through renderOutputsList, replacing the hand-kept list beside it. The snapshot guide's fleet example says the uploaded directory holds redacted targets' full documents in the clear and points at the redaction guide's artifact-visibility note. --- .github/scripts/gen-docs.ts | 11 ++++++++--- docs/operate/snapshot.md | 2 +- docs/reference/inputs.md | 2 +- src/problem.ts | 2 +- test/action/action-yml.test.ts | 22 ++++++++++++++-------- test/action/run.test.ts | 2 +- test/problem.test.ts | 2 +- test/scripts/gen-docs.test.ts | 6 +++--- 8 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index 2b528ca62..1c945bbfc 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { REPO_RESULTS, type RepoResult } from "../../src/engine/orchestrate.js"; import { MERGE_RESULT } from "../../src/flows/deliver.js"; +import { SNAPSHOT_RESULTS } from "../../src/flows/snapshot.js"; import type { SectionDocs } from "../../src/sections/contract/docs.js"; import { type SectionMeta, @@ -239,8 +240,12 @@ const RESULT_DISPLAY: Record = { const WORST_OF = "; worst-of across targets in multi-repo mode"; const CAN_ALSO_APPEAR = " can also appear"; -/** The merge-mode result, a value outside RepoResult, closes the enumeration. */ +/** The merge- and snapshot-mode results, values outside RepoResult, close the enumeration. */ const MERGE_ONLY = `; \`${MERGE_RESULT}\` in mode: merge`; +const SNAPSHOT_ONLY = `; ${[...SNAPSHOT_RESULTS] + .reverse() + .map((value) => `\`${value}\``) + .join(" / ")} in mode: snapshot`; export function renderOutputsList(results: readonly RepoResult[]): string { const ordered = (Object.keys(RESULT_DISPLAY) as RepoResult[]).filter((value) => @@ -254,7 +259,7 @@ export function renderOutputsList(results: readonly RepoResult[]): string { const lead = `${anyMode.join(" / ")}${WORST_OF}`; const withMulti = multiOnly.length === 0 ? lead : `${lead}, where ${multiOnly.join(" and ")}${CAN_ALSO_APPEAR}`; - return `${withMulti}${MERGE_ONLY}`; + return `${withMulti}${MERGE_ONLY}${SNAPSHOT_ONLY}`; } export type TaggedOperation = Pick & { @@ -337,7 +342,7 @@ function outputsListRegion(name: string, heading: string): GeneratedRegion { body: new RegExp( String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${escapeRe(WORST_OF)}(?:, where ` + String.raw`\x60[a-z]+\x60(?: and ` + - String.raw`\x60[a-z]+\x60)*${escapeRe(CAN_ALSO_APPEAR)})?(?:${escapeRe(MERGE_ONLY)})?)?$`, + String.raw`\x60[a-z]+\x60)*${escapeRe(CAN_ALSO_APPEAR)})?(?:${escapeRe(MERGE_ONLY)})?(?:${escapeRe(SNAPSHOT_ONLY)})?)?$`, ), render: () => renderOutputsList(REPO_RESULTS), }; diff --git a/docs/operate/snapshot.md b/docs/operate/snapshot.md index b0f2f7d86..c586afd5b 100644 --- a/docs/operate/snapshot.md +++ b/docs/operate/snapshot.md @@ -156,7 +156,7 @@ jobs: Copy the directory in as your `repos-dir` to bring the fleet under management, one reviewable file per repository. The step summary lists every target with its result and file, then one section table per target, and the `repos-result` output carries the per-repository results as JSON. -Private and internal targets are redacted by default, exactly as in a multi-repo apply (see [private repositories](private-repositories.md)): their file is written like the others, but the public surfaces know them only as `private repository #N`, with the file name and every detail hidden. Their notes are in their file header; there is no report channel in snapshot mode. +Private and internal targets are redacted by default, exactly as in a multi-repo apply (see [private repositories](private-repositories.md)): their file is written like the others, but the public surfaces know them only as `private repository #N`, with the file name and every detail hidden. Their notes are in their file header; there is no report channel in snapshot mode. The uploaded `snapshots` artifact therefore holds those targets' full documents in the clear, and an artifact inherits the admin repository's visibility, so on a public admin repository encrypt it or skip the upload (see [what redaction does and does not protect](private-repositories.md#what-redaction-does-and-does-not-protect)). ## Inputs in mode: snapshot diff --git a/docs/reference/inputs.md b/docs/reference/inputs.md index eb9d3f009..56010fd50 100644 --- a/docs/reference/inputs.md +++ b/docs/reference/inputs.md @@ -41,7 +41,7 @@ The discovery-only inputs apply to `repos: "*"`; the [multi-repo guide](../opera ## Outputs -- `result`: `applied` / `partial` / `clean` / `drift` / `failed`; worst-of across targets in multi-repo mode, where `skipped` can also appear; `merged` in mode: merge. In `mode: snapshot` it reads `snapshot`, `partial`, or `failed`; the [snapshot guide](../operate/snapshot.md) has that table. +- `result`: `applied` / `partial` / `clean` / `drift` / `failed`; worst-of across targets in multi-repo mode, where `skipped` can also appear; `merged` in mode: merge; `snapshot` / `partial` / `failed` in mode: snapshot. The [snapshot guide](../operate/snapshot.md) has the snapshot values' exit-code table. - `skipped-sections`: the sections skipped for missing permissions under `on-missing-permission: warn`, comma-separated (a deduped union across targets in multi-repo mode). - `repos-result`: multi-repo mode only, a JSON map of `owner/name` to `{result, source, skippedSections}`. A redacted private target is keyed by its `private repository #N` placeholder instead of its slug; see [Private repositories](../operate/private-repositories.md). diff --git a/src/problem.ts b/src/problem.ts index 4efae0d12..94814869a 100644 --- a/src/problem.ts +++ b/src/problem.ts @@ -413,7 +413,7 @@ export function describeProblem(problem: Problem): string { `the ${quoteList(problem.inputs)} input(s) do not apply to mode: snapshot, which only reads the ` + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + - "the apply or check step that runs the snapshot" + "the apply, check, or merge step they belong to" ); case "input-snapshot-destination-missing": return 'mode: snapshot needs exactly one of the "snapshot-file" input (one repository\'s settings written to that file) or the "snapshot-dir" input (one /.yml per repos or repos-dir target under that directory). Set one of them'; diff --git a/test/action/action-yml.test.ts b/test/action/action-yml.test.ts index 8e82d9619..bc666b538 100644 --- a/test/action/action-yml.test.ts +++ b/test/action/action-yml.test.ts @@ -56,14 +56,20 @@ describe("input declarations <-> discovery defaults", () => { }); describe("output declarations", () => { - test("the result description mentions every RepoResult value, the merge result, and the snapshot results", () => { - // MERGE_RESULT and SNAPSHOT_RESULTS are the values outside REPO_RESULTS (neither mode has a target). - const missing = [...REPO_RESULTS, MERGE_RESULT, ...SNAPSHOT_RESULTS].filter( - (value) => !OUTPUT_DECLS.result.description.includes(value), + test("the result description enumerates exactly the RepoResult values, the merge result, and the snapshot results", () => { + // The enumerated values are the `a | b | c` chains; a value named only in prose ("in mode: snapshot") does not count. + const { description } = OUTPUT_DECLS.result; + const enumerated = (description.match(/[a-z]+(?: \| [a-z]+)+/g) ?? []).flatMap((chain) => + chain.split(" | "), + ); + expect(new Set(enumerated)).toEqual( + new Set([...REPO_RESULTS.filter((value) => value !== "skipped"), ...SNAPSHOT_RESULTS]), + ); + expect(description).toContain("where skipped can also appear"); + expect(description).toContain(`${MERGE_RESULT} in mode: merge`); + // SNAPSHOT_RESULTS is worst-first; the description reads best-first like the RepoResult chain. + expect(description).toContain( + `${[...SNAPSHOT_RESULTS].reverse().join(" | ")} in mode: snapshot`, ); - expect( - missing, - `the "result" output description omits result value(s): ${missing.join(", ")}`, - ).toEqual([]); }); }); diff --git a/test/action/run.test.ts b/test/action/run.test.ts index 42629e52c..32fb59acd 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -877,7 +877,7 @@ describe("run in mode: snapshot", () => { 'error: the "settings-file" input(s) do not apply to mode: snapshot, which only reads the ' + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + - "the apply or check step that runs the snapshot", + "the apply, check, or merge step they belong to", "result: failed", ]); }); diff --git a/test/problem.test.ts b/test/problem.test.ts index a7345d2c4..bd4aa4ae0 100644 --- a/test/problem.test.ts +++ b/test/problem.test.ts @@ -100,7 +100,7 @@ const SPECIMENS = { 'the "settings-file", "layering" input(s) do not apply to mode: snapshot, which only reads the ' + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + - "the apply or check step that runs the snapshot", + "the apply, check, or merge step they belong to", ], "input-snapshot-destination-missing": [ { code: "input-snapshot-destination-missing" }, diff --git a/test/scripts/gen-docs.test.ts b/test/scripts/gen-docs.test.ts index d1b604c2e..ce06622b8 100644 --- a/test/scripts/gen-docs.test.ts +++ b/test/scripts/gen-docs.test.ts @@ -280,12 +280,12 @@ describe("renderPatCell", () => { }); describe("renderOutputsList", () => { - test("lists the any-mode values in display order, then the multi-repo-only ones, then the merge result", () => { + test("lists the any-mode values in display order, then the multi-repo-only ones, then the merge and snapshot results", () => { expect(renderOutputsList(REPO_RESULTS)).toBe( - "`applied` / `partial` / `clean` / `drift` / `failed`; worst-of across targets in multi-repo mode, where `skipped` can also appear; `merged` in mode: merge", + "`applied` / `partial` / `clean` / `drift` / `failed`; worst-of across targets in multi-repo mode, where `skipped` can also appear; `merged` in mode: merge; `snapshot` / `partial` / `failed` in mode: snapshot", ); expect(renderOutputsList(["failed", "applied"])).toBe( - "`applied` / `failed`; worst-of across targets in multi-repo mode; `merged` in mode: merge", + "`applied` / `failed`; worst-of across targets in multi-repo mode; `merged` in mode: merge; `snapshot` / `partial` / `failed` in mode: snapshot", ); }); }); From 97e6bd6bb12798b9eab93736ded07ef0e7b5f6b8 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:21:44 -0400 Subject: [PATCH 6/9] fix(snapshot): follow main's CLI with a snapshot subcommand over the shared parser Main's CLI (#152) is total over the parsed modes: its dispatcher and per-mode flag split are typed over RunConfig and MODES, so the snapshot arm needs a home. The dispatcher concludes a snapshot config the way the action does, inputsForMode derives each mode's flags from the exported per-mode input lists (MERGE_ONLY_INPUTS and SNAPSHOT_ONLY_INPUTS now exported), and the program gains the snapshot subcommand with its guide row. --- docs/start/cli.md | 12 ++- src/cli/commands.ts | 8 ++ src/cli/inputs.ts | 28 ++++--- src/cli/program.ts | 22 +++-- src/flows/inputs.ts | 7 +- src/index.ts | 2 + test/cli/inputs.test.ts | 121 ++++++++++++++++++++++++++-- test/cli/program.test.ts | 47 +++++++++++ test/library/public-surface.test.ts | 2 + 9 files changed, 224 insertions(+), 25 deletions(-) diff --git a/docs/start/cli.md b/docs/start/cli.md index 5b1707f0d..939fd8833 100644 --- a/docs/start/cli.md +++ b/docs/start/cli.md @@ -33,6 +33,7 @@ The package needs Node 22.14 or newer; `bunx` fetches it the same way and honors | `check` | Report drift between a settings file and the live repository; exits 1 on any drift | yes | | `apply` | Apply a settings file to the repository | yes | | `merge` | Fold an ordered list of settings files into one document | no | +| `snapshot` | Write a repository's live settings as a settings file, or one file per multi-repo target | yes | | `validate ` | Validate a settings file against the schema | no | | `permissions ` | Print the PAT grant each section the file declares needs | no | @@ -65,6 +66,15 @@ gsac merge --settings-file fleet.yml --settings-file team.yml --merged-file merg A repeated `--settings-file` builds the layer list, lowest first; a comma-separated value does the same. The merged file is exactly what `apply` would run ([layering](../operate/layering.md)). +### snapshot + +```bash +gsac snapshot --token "$ADMIN_TOKEN" --repository octocat/hello-world --snapshot-file settings.snapshot.yml +gsac snapshot --token "$FLEET_TOKEN" --repos "*" --snapshot-dir snapshots +``` + +Exactly one of `--snapshot-file` (one repository) and `--snapshot-dir` (one `/.yml` per target) is required; the fleet flags are the multi-repo ones. The written file is what `check` reads clean against the repository it came from ([snapshot mode](../operate/snapshot.md)). + ### validate ```bash @@ -114,7 +124,7 @@ With `--json` the outputs are one object instead, and stdout carries nothing els {"skipped-sections":"","result":"clean"} ``` -The exit codes are the action's: `check` exits 1 on drift or failure, `apply` and `merge` exit 1 on failure only, `validate` exits 1 on an invalid file, `permissions` exits 0 for a valid file. +The exit codes are the action's: `check` exits 1 on drift or failure, `apply`, `merge`, and `snapshot` exit 1 on failure only, `validate` exits 1 on an invalid file, `permissions` exits 0 for a valid file. A flag the mode does not read is unknown to that subcommand and exits 1 with the parser's message, where the action rejects the same input by name; a value the mode refuses (a filter without `--repos "*"`, a `--repository` that is not a slug) fails with the action's own message, reworded where the action's remedy names the workflow step. The token passed on the command line is masked as `***` wherever a line would echo it. diff --git a/src/cli/commands.ts b/src/cli/commands.ts index bd713d0a7..8d2beca8c 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -8,6 +8,7 @@ import { concludeMerge, concludeRun, + concludeSnapshot, describeProblem, failRun, type GithubClient, @@ -20,6 +21,7 @@ import { runMerge, runMulti, runSingle, + runSnapshot, SECTIONS, type SectionModule, sectionGrant, @@ -49,6 +51,12 @@ export async function runConfig(cfg: RunConfig, io: Io, host: CliHost): Promise< ); } const api = host.createClient(cfg.token, io, cfg.apiVersion); + if (cfg.kind === "snapshot") { + return runSnapshot(api, cfg, io).match( + (finished) => concludeSnapshot(io, finished), + (problem) => failRun(io, problem), + ); + } if (cfg.kind === "multi") { return runMulti(api, cfg, io).match( (targets) => concludeRun(io, { kind: "multi", mode: cfg.mode, targets }), diff --git a/src/cli/inputs.ts b/src/cli/inputs.ts index 53e6a1e00..b6593697e 100644 --- a/src/cli/inputs.ts +++ b/src/cli/inputs.ts @@ -14,8 +14,11 @@ import { type InputName, type InputReader, MERGE_INPUTS, + MERGE_ONLY_INPUTS, MODES, type Mode, + SNAPSHOT_INPUTS, + SNAPSHOT_ONLY_INPUTS, } from "../index.js"; /** Declaration order is the help order, as on the inputs reference page. */ @@ -30,19 +33,22 @@ const PROGRAM_INPUTS = ["mode", "token"] as const satisfies readonly InputName[] */ export const CLI_UNSUPPORTED_INPUTS = ["report-public-key"] as const satisfies readonly InputName[]; -/** - * The flags a mode's subcommand takes: the inputs its mode reads. `settings-file` - * is the one input both the merge and the engine modes read. - */ +/** The flags a mode's subcommand takes: the inputs its mode reads, in declaration order. */ export function inputsForMode(mode: Mode): InputName[] { - const mergeReads = (name: InputName): boolean => - (MERGE_INPUTS as readonly InputName[]).includes(name); const hidden: readonly InputName[] = [...PROGRAM_INPUTS, ...CLI_UNSUPPORTED_INPUTS]; - return INPUT_NAMES.filter( - (name) => - !hidden.includes(name) && - (mode === "merge" ? mergeReads(name) : !mergeReads(name) || name === "settings-file"), - ); + const modeOnly: readonly InputName[] = [...MERGE_ONLY_INPUTS, ...SNAPSHOT_ONLY_INPUTS]; + const reads = (name: InputName): boolean => { + switch (mode) { + case "merge": + return (MERGE_INPUTS as readonly InputName[]).includes(name); + case "snapshot": + return (SNAPSHOT_INPUTS as readonly InputName[]).includes(name); + case "apply": + case "check": + return !modeOnly.includes(name); + } + }; + return INPUT_NAMES.filter((name) => !hidden.includes(name) && reads(name)); } /** Every input some subcommand or the program exposes; the mode is the subcommand itself. */ diff --git a/src/cli/program.ts b/src/cli/program.ts index 974f50a2d..60a1941f8 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,5 +1,5 @@ /** - * The command tree: check, apply, and merge mirror the action's modes with + * The command tree: check, apply, merge, and snapshot mirror the action's modes with * INPUT_DECLS as their flags; validate and permissions read a file alone. * `--token`, `--json`, `--summary`, and `--verbose` are global. main() runs * argv to its exit code without touching the process. @@ -29,7 +29,14 @@ import { argvReader, inputOption, inputsForMode, once, tokenValues } from "./inp import { type CliStreams, cliIo, type MaskedStreams, maskedStreams } from "./io.js"; /** Every subcommand, in help order; the package smoke asserts the installed help names each. */ -export const CLI_COMMANDS = ["check", "apply", "merge", "validate", "permissions"] as const; +export const CLI_COMMANDS = [ + "check", + "apply", + "merge", + "snapshot", + "validate", + "permissions", +] as const; type CliCommand = (typeof CLI_COMMANDS)[number]; @@ -37,14 +44,19 @@ const DESCRIPTION: Readonly> = { check: "Report drift between a settings file and the live repository; exits 1 on any drift", apply: "Apply a settings file to the repository", merge: "Fold an ordered list of settings files into one document, with no token and no API call", + snapshot: + "Write a repository's live settings as a settings file, or one file per multi-repo target under a directory", validate: "Validate a settings file against the schema; no token, no API call", permissions: "Print the PAT grant each section a settings file declares needs", }; /** The subcommands that run the engine or the merge, each under its mode. */ -const MODE_COMMANDS = { check: "check", apply: "apply", merge: "merge" } as const satisfies Partial< - Record ->; +const MODE_COMMANDS = { + check: "check", + apply: "apply", + merge: "merge", + snapshot: "snapshot", +} as const satisfies Partial>; interface Globals { readonly token?: string; diff --git a/src/flows/inputs.ts b/src/flows/inputs.ts index 97a9c9934..397139375 100644 --- a/src/flows/inputs.ts +++ b/src/flows/inputs.ts @@ -405,8 +405,11 @@ type _UnlistedLayering = MustBeNever config equals env -> config", () => { envToken++; } } - expect([...kinds].sort()).toEqual(["merge", "multi", "single"]); + expect([...kinds].sort()).toEqual(["merge", "multi", "single", "snapshot"]); expect(envToken).toBeGreaterThan(0); expect(rejected).toBeGreaterThanOrEqual(5); }); @@ -305,6 +370,8 @@ const VALID: Record, string> = { repository: "o/r", "settings-file": "s.yml", "merged-file": "out.yml", + "snapshot-file": "snap.yml", + "snapshot-dir": "snaps", "on-missing-permission": "warn", "required-sections": "labels", sections: "labels", @@ -338,7 +405,39 @@ const COMPANIONS: Partial> = { /** The smallest input set each mode accepts. */ function base(mode: Mode): Inputs { - return mode === "merge" ? { mode, "merged-file": "out.yml" } : { mode, token: "ghp" }; + switch (mode) { + case "merge": + return { mode, "merged-file": "out.yml" }; + case "snapshot": + return { mode, token: "ghp", "snapshot-file": "snap.yml" }; + default: + return { mode, token: "ghp" }; + } +} + +/** + * The snapshot's fleet inputs need the directory form: they unset the base's + * snapshot-file (an empty value reads as unset) and name a snapshot-dir. + */ +const SNAPSHOT_FLEET: Inputs = { "snapshot-file": "", "snapshot-dir": "snaps", repos: "*" }; +const SNAPSHOT_FLEET_INPUTS = new Set([ + "snapshot-dir", + "repos", + "repos-dir", + "visibility", + "archived", + "forks", + "exclude", + "topics", + "affiliation", +]); + +/** The inputs a flag needs beside it under `mode`, on top of that mode's base. */ +function companions(mode: Mode, name: InputName): Inputs { + if (mode === "snapshot" && SNAPSHOT_FLEET_INPUTS.has(name)) { + return name === "repos-dir" ? { ...SNAPSHOT_FLEET, repos: "" } : SNAPSHOT_FLEET; + } + return COMPANIONS[name] ?? {}; } describe("the per-mode flag split", () => { @@ -350,7 +449,7 @@ describe("the per-mode flag split", () => { const value = name === "report-public-key" ? recipient : VALID[name]; if (exposed.has(name)) { const result = parseConfig( - recordReader({ ...base(mode), ...COMPANIONS[name], [name]: value }), + recordReader({ ...base(mode), ...companions(mode, name), [name]: value }), env, ); expect( @@ -366,7 +465,9 @@ describe("the per-mode flag split", () => { (problem) => problem.code, ), `${mode} --${name}`, - ).toMatch(/^input-(merge-only|rejected-in-merge|report-key-unused)$/); + ).toMatch( + /^input-(merge-only|snapshot-only|rejected-in-merge|rejected-in-snapshot|report-key-unused)$/, + ); } } } @@ -397,6 +498,8 @@ describe("the per-mode flag split", () => { }, mode: "single", "merged-file": "single", + "snapshot-file": "single", + "snapshot-dir": "single", "on-missing-permission": "single", "required-sections": { argv: check, @@ -458,8 +561,14 @@ describe("the per-mode flag split", () => { if (!exposed.has(name)) { continue; // the mode is the subcommand; an unsupported input has no flag } - // The base argv minus this flag, so the two repeats below are its only occurrences. - const base = inputsForMode("check").includes(name) || name === "token" ? check : ["merge"]; + // The base argv of the first subcommand exposing the flag, minus this flag, so the two + // repeats below are its only occurrences. + const base = + inputsForMode("check").includes(name) || name === "token" + ? check + : inputsForMode("merge").includes(name) + ? ["merge"] + : ["snapshot", "--token", "ghp"]; const flag = base.filter((argument, index) => { const value = index > 0 && base[index - 1] === `--${name}`; return argument !== `--${name}` && !value; diff --git a/test/cli/program.test.ts b/test/cli/program.test.ts index 86ea35df0..5e64636d0 100644 --- a/test/cli/program.test.ts +++ b/test/cli/program.test.ts @@ -281,6 +281,53 @@ describe("merge", () => { }); }); +describe("snapshot", () => { + const LABEL = { name: "bug", color: "d73a4a", description: "Something is broken" }; + + test("writes the live settings to the snapshot file, exits 0, and reports snapshot", async () => { + const out = join(tempDir(), "out", "snapshot.yml"); + const api = new MockApi({ + "GET /repos/o/r": { data: { private: false } }, + "GET /repos/o/r/labels?per_page=100&page=1": { data: [LABEL] }, + }); + const result = await cli( + [ + "snapshot", + "--token", + TOKEN, + "--repository", + "o/r", + "--snapshot-file", + out, + "--sections", + "labels", + ], + api, + ); + expect(result).toEqual({ + code: 0, + stdout: `snapshot written to ${out}\nresult: snapshot\nskipped-sections=\nresult=snapshot\n`, + stderr: "", + }); + expect(api.mutations()).toEqual([]); + expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ + labels: { _undeclared: "delete", entries: [LABEL] }, + }); + }); + + test("a snapshot without a destination fails before any API call, the action's line on stderr", async () => { + const api = new MockApi({}); + const result = await cli(["snapshot", "--token", TOKEN, "--repository", "o/r"], api); + expect(api.calls).toHaveLength(0); + expect(result).toEqual({ + code: 1, + stdout: "result: failed\nskipped-sections=\nresult=failed\n", + stderr: + 'error: mode: snapshot needs exactly one of the "snapshot-file" input (one repository\'s settings written to that file) or the "snapshot-dir" input (one /.yml per repos or repos-dir target under that directory). Set one of them\n', + }); + }); +}); + describe("validate and permissions", () => { test("validate: a valid file exits 0 naming its sections; --json gives the verdict as an object", async () => { const plain = await cli(["validate", SINGLE]); diff --git a/test/library/public-surface.test.ts b/test/library/public-surface.test.ts index 7fcae4aed..ffaad207f 100644 --- a/test/library/public-surface.test.ts +++ b/test/library/public-surface.test.ts @@ -64,6 +64,7 @@ describe("the public entry", () => { "MARKER_LABEL", "MARKER_LABEL_CONFIG", "MERGE_INPUTS", + "MERGE_ONLY_INPUTS", "MERGE_REJECTED_INPUTS", "MERGE_RESULT", "MODES", @@ -102,6 +103,7 @@ describe("the public entry", () => { "SECTIONS", "SECTION_KEYS", "SNAPSHOT_INPUTS", + "SNAPSHOT_ONLY_INPUTS", "SNAPSHOT_REJECTED_INPUTS", "SNAPSHOT_RESULTS", "SNAPSHOT_SCHEMA_URL", From b6ecdd3269a01795689b5591023595a9ad2dfde1 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:02:13 -0400 Subject: [PATCH 7/9] fix(snapshot): write the snapshot through a staging file renamed into place A write that failed partway (disk full, an interrupted run) truncated a previously valid snapshot at the destination while the run reported failed, so the documented backup was lost. The document now goes to a sibling .tmp and is renamed over the destination, which is atomic for a regular file; the staging file is removed when the write fails, and a cleanup that fails itself does not replace the write's error. Both destination forms write through the one helper. Tests fail the staging write and read the previous snapshot back intact, and fail the rename under the dir form to see the staging file gone and the other target written. --- src/flows/snapshot.ts | 27 +++++++++++++-- test/flows/snapshot.test.ts | 69 ++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/flows/snapshot.ts b/src/flows/snapshot.ts index 8b736f565..665c43629 100644 --- a/src/flows/snapshot.ts +++ b/src/flows/snapshot.ts @@ -10,7 +10,7 @@ * on disk while nothing about it is printed. */ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { err, ok, type Result, ResultAsync } from "neverthrow"; import type { RepoRef, Target } from "../discovery/targets.js"; @@ -228,8 +228,7 @@ async function snapshotTarget(ctx: { }; } try { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync( + writeReplacing( path, renderSnapshotYaml(result, { schemaUrl: SNAPSHOT_SCHEMA_URL, timestamp: ctx.timestamp }), ); @@ -253,6 +252,28 @@ async function snapshotTarget(ctx: { }; } +/** + * Write `text` to `path` through a sibling staging file renamed into place, so + * a write that fails partway (disk full, an interrupted run) leaves the + * previous snapshot at `path` intact instead of a truncated one; the rename is + * atomic for a regular file on POSIX and Windows alike. The staging file is + * removed when the write fails. + */ +function writeReplacing(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }); + const staging = `${path}.tmp`; + try { + writeFileSync(staging, text); + renameSync(staging, path); + } catch (error) { + // The write's error is the one reported: a directory at the staging path fails both the write and this rm. + try { + rmSync(staging, { force: true }); + } catch {} + throw error; + } +} + /** * A target's file under the snapshot directory, in the repos-dir layout so the * directory can later serve as one. SLUG_RE admits "." and "..", which GitHub diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index c11f8354e..5c7d066bc 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { parse as parseYaml } from "yaml"; import { parseRepoSlug } from "../../src/discovery/targets.js"; import { SectionSelection } from "../../src/engine/section-selection.js"; @@ -241,6 +241,73 @@ function disjointRefusal(snapshotDir: string, reposDir: string): string { ); } +describe("runSnapshot writes through a staging file", () => { + const unwritable = (path: string, input: "snapshot-file" | "snapshot-dir", os: string) => ({ + level: "error" as const, + line: `cannot write the snapshot to ${path}: Error: ${os}. Check that the "${input}" input names a writable path`, + }); + + test("a staging write that fails leaves the previous snapshot intact and reports the write's error", async () => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg(); + const staging = `${cfg.snapshotFile}.tmp`; + mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); + writeFileSync(cfg.snapshotFile, "labels: []\n"); + // A directory at the staging path fails the write before the rename; the run must not remove it either. + mkdirSync(staging); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(collected.outputs.result).toBe("failed"); + expect(collected.lines[0]).toEqual( + unwritable( + cfg.snapshotFile, + "snapshot-file", + `EISDIR: illegal operation on a directory, open '${staging}'`, + ), + ); + expect(readFileSync(cfg.snapshotFile, "utf8")).toBe("labels: []\n"); + rmSync(staging, { recursive: true }); + expect(await run(api, cfg, collectingIo().io)).toBe(0); + expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); + expect(existsSync(staging)).toBe(false); + }); + + test("a rename that fails removes the staging file, fails only that target, and leaves the destination as it was", async () => { + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/b": { data: { private: false } }, + ...labelsRoute("o/a", [BUG]), + ...labelsRoute("o/b", [DOCS]), + }); + const cfg = dirCfg(); + const fileA = join(cfg.snapshotDir, "o", "a.yml"); + const fileB = join(cfg.snapshotDir, "o", "b.yml"); + // A directory holding a file at o/a's destination lets the staging write succeed and the rename fail. + mkdirSync(fileA, { recursive: true }); + writeFileSync(join(fileA, "keep"), "authored\n"); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "failed", + "repos-result": JSON.stringify({ + "o/a": { result: "failed", source: "remote", skippedSections: [] }, + "o/b": { result: "snapshot", source: "remote", skippedSections: [] }, + }), + }); + const { level, line } = unwritable( + fileA, + "snapshot-dir", + `EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`, + ); + expect(collected.lines[0]).toEqual({ level, line: `o/a: ${line}` }); + expect(existsSync(`${fileA}.tmp`)).toBe(false); + expect(readFileSync(join(fileA, "keep"), "utf8")).toBe("authored\n"); + expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); + expect(existsSync(`${fileB}.tmp`)).toBe(false); + }); +}); + describe("runSnapshot refuses a destination that would overwrite an authored file", () => { test.each([ [ From 4f617d8330e035e5b89248a4734cc633891b6146 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:05:55 -0400 Subject: [PATCH 8/9] fix(snapshot): never write the staging file through a leftover link, and pin the mode subcommands A stale symlink at .tmp (out/s.yml.tmp -> s.yml) was written through and then installed as the destination, so the run printed "snapshot written" while the destination became a self-pointing link and both the old and the new content were gone. The staging path is unlinked before the write (a directory there still fails loudly) and created exclusively, and a test runs the link case. The Windows note on the helper now says what Node's rename is there, a single replace call, rather than claiming atomicity. Beside it: the staging tests assert the full outputs record and lines array like the rest of the file; the CLI's mode subcommands gain the lockstep type that fails when a Mode has no subcommand; the snapshot fleet-input set in the CLI test spreads FILTER_INPUTS instead of hand-listing them; and the README's snapshot pointer moves from step 3's check-mode bullet to step 2's settings-file line, where starting from a snapshot belongs. --- README.md | 4 +-- src/cli/program.ts | 4 +++ src/flows/snapshot.ts | 7 ++--- test/cli/inputs.test.ts | 8 ++---- test/flows/snapshot.test.ts | 51 ++++++++++++++++++++++++++++++------- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1086872e6..78839e555 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state 1. Create a [fine-grained PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) from the [pre-filled token form][pat-form] and save it as the `ADMIN_TOKEN` repository secret. The form starts with every repository permission the action can need (an organization owner adds Members: read by hand); the default `GITHUB_TOKEN` can never hold them. -2. Add `.github/settings.yml`. The first line gives editor autocomplete and hover docs: +2. Add `.github/settings.yml` (or start from a [snapshot](docs/operate/snapshot.md) of the live settings). The first line gives editor autocomplete and hover docs: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/Vivswan/github-settings-as-code/v2/lib/settings.schema.json # x-release-please-major @@ -23,7 +23,7 @@ Apply declarative repository settings from `.github/settings.yml`: a loud, state 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. The [getting started guide](docs/start/getting-started.md) explains the drift output; [Snapshot mode](docs/operate/snapshot.md) writes an existing repository's live settings as the file to start from. + - Drop the `mode: check` line once the report says what you expect. The [getting started guide](docs/start/getting-started.md) explains the drift output. ```yaml # .github/workflows/settings.yml diff --git a/src/cli/program.ts b/src/cli/program.ts index 60a1941f8..b18299c60 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -13,6 +13,7 @@ import { INPUT_DECLS, type Io, type Mode, + type MustBeNever, parseConfig, type RunConfig, } from "../index.js"; @@ -58,6 +59,9 @@ const MODE_COMMANDS = { snapshot: "snapshot", } as const satisfies Partial>; +/** Compile-time lockstep: a Mode without a subcommand fails here. */ +type _UnlistedMode = MustBeNever>; + interface Globals { readonly token?: string; readonly json?: boolean; diff --git a/src/flows/snapshot.ts b/src/flows/snapshot.ts index 665c43629..f968d6f14 100644 --- a/src/flows/snapshot.ts +++ b/src/flows/snapshot.ts @@ -256,14 +256,15 @@ async function snapshotTarget(ctx: { * Write `text` to `path` through a sibling staging file renamed into place, so * a write that fails partway (disk full, an interrupted run) leaves the * previous snapshot at `path` intact instead of a truncated one; the rename is - * atomic for a regular file on POSIX and Windows alike. The staging file is - * removed when the write fails. + * atomic on POSIX and a single replace call on Windows. A leftover staging + * file or link is unlinked first, never written through. */ function writeReplacing(path: string, text: string): void { mkdirSync(dirname(path), { recursive: true }); const staging = `${path}.tmp`; try { - writeFileSync(staging, text); + rmSync(staging, { force: true }); + writeFileSync(staging, text, { flag: "wx" }); renameSync(staging, path); } catch (error) { // The write's error is the one reported: a directory at the staging path fails both the write and this rm. diff --git a/test/cli/inputs.test.ts b/test/cli/inputs.test.ts index 7d245764a..5d14f81d2 100644 --- a/test/cli/inputs.test.ts +++ b/test/cli/inputs.test.ts @@ -23,6 +23,7 @@ import { buildProgram, CLI_COMMANDS, main } from "../../src/cli/program.js"; import { type ConfigEnv, describeProblem, + FILTER_INPUTS, INPUT_DECLS, type InputDecl, type InputName, @@ -424,12 +425,7 @@ const SNAPSHOT_FLEET_INPUTS = new Set([ "snapshot-dir", "repos", "repos-dir", - "visibility", - "archived", - "forks", - "exclude", - "topics", - "affiliation", + ...FILTER_INPUTS, ]); /** The inputs a flag needs beside it under `mode`, on top of that mode's base. */ diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index 5c7d066bc..bac8ea4e5 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -8,7 +8,16 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { parse as parseYaml } from "yaml"; @@ -244,7 +253,7 @@ function disjointRefusal(snapshotDir: string, reposDir: string): string { describe("runSnapshot writes through a staging file", () => { const unwritable = (path: string, input: "snapshot-file" | "snapshot-dir", os: string) => ({ level: "error" as const, - line: `cannot write the snapshot to ${path}: Error: ${os}. Check that the "${input}" input names a writable path`, + line: `cannot write the snapshot to ${path}: ${os}. Check that the "${input}" input names a writable path`, }); test("a staging write that fails leaves the previous snapshot intact and reports the write's error", async () => { @@ -253,18 +262,19 @@ describe("runSnapshot writes through a staging file", () => { const staging = `${cfg.snapshotFile}.tmp`; mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); writeFileSync(cfg.snapshotFile, "labels: []\n"); - // A directory at the staging path fails the write before the rename; the run must not remove it either. + // A directory at the staging path fails the run before the rename; the run must not remove it either. mkdirSync(staging); const collected = collectingIo(); expect(await run(api, cfg, collected.io)).toBe(1); - expect(collected.outputs.result).toBe("failed"); - expect(collected.lines[0]).toEqual( + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "failed" }); + expect(collected.lines).toEqual([ unwritable( cfg.snapshotFile, "snapshot-file", - `EISDIR: illegal operation on a directory, open '${staging}'`, + `SystemError [ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) ${staging}`, ), - ); + { line: "result: failed" }, + ]); expect(readFileSync(cfg.snapshotFile, "utf8")).toBe("labels: []\n"); rmSync(staging, { recursive: true }); expect(await run(api, cfg, collectingIo().io)).toBe(0); @@ -272,6 +282,25 @@ describe("runSnapshot writes through a staging file", () => { expect(existsSync(staging)).toBe(false); }); + test("a leftover link at the staging path is unlinked, never written through: the destination becomes a regular file", async () => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg(); + const staging = `${cfg.snapshotFile}.tmp`; + mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); + writeFileSync(cfg.snapshotFile, "labels: []\n"); + symlinkSync("snapshot.yml", staging); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + expect(collected.outputs).toEqual({ "skipped-sections": "", result: "snapshot" }); + expect(collected.lines).toEqual([ + { line: `snapshot written to ${cfg.snapshotFile}` }, + { line: "result: snapshot" }, + ]); + expect(lstatSync(cfg.snapshotFile).isSymbolicLink()).toBe(false); + expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); + expect(existsSync(staging)).toBe(false); + }); + test("a rename that fails removes the staging file, fails only that target, and leaves the destination as it was", async () => { const api = new MockApi({ "GET /repos/o/a": { data: { private: false } }, @@ -298,9 +327,13 @@ describe("runSnapshot writes through a staging file", () => { const { level, line } = unwritable( fileA, "snapshot-dir", - `EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`, + `Error: EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`, ); - expect(collected.lines[0]).toEqual({ level, line: `o/a: ${line}` }); + expect(collected.lines).toEqual([ + { level, line: `o/a: ${line}` }, + { line: `o/b: snapshot written to ${fileB}` }, + { line: "result: failed" }, + ]); expect(existsSync(`${fileA}.tmp`)).toBe(false); expect(readFileSync(join(fileA, "keep"), "utf8")).toBe("authored\n"); expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); From 5af7e4c081f106bea09ecab3915159ceb41024d2 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:35:03 -0400 Subject: [PATCH 9/9] fix(snapshot): the directory summary counts the files it wrote instead of claiming all of them With every target failed (one target, its read denied under the fail policy), the step summary opened with "Snapshots written under ." while the table showed no file and the result read failed. The heading now follows the count of views that carry a file: no snapshot written, N of M written, or the unchanged line when every target wrote one. Tests pin the all-failed and the mixed summary. --- src/flows/summary.ts | 7 +++++- test/flows/snapshot.test.ts | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/flows/summary.ts b/src/flows/summary.ts index 446b5fc4a..8f5fae84c 100644 --- a/src/flows/summary.ts +++ b/src/flows/summary.ts @@ -116,10 +116,15 @@ export function writeSnapshotDirSummary( views: readonly SnapshotTargetView[], snapshotDir: string, ): void { + const written = views.filter((view) => view.file !== undefined).length; const lines = [ `## github-settings-as-code (snapshot, ${views.length} repositories)`, "", - `Snapshots written under ${markdownCell(snapshotDir)}.`, + written === 0 + ? `No snapshot was written under ${markdownCell(snapshotDir)}.` + : written === views.length + ? `Snapshots written under ${markdownCell(snapshotDir)}.` + : `${written} of ${views.length} snapshots written under ${markdownCell(snapshotDir)}.`, "", "| Repository | Source | Result | File |", "|---|---|---|---|", diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index bac8ea4e5..1361849e7 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -338,6 +338,16 @@ describe("runSnapshot writes through a staging file", () => { expect(readFileSync(join(fileA, "keep"), "utf8")).toBe("authored\n"); expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); expect(existsSync(`${fileB}.tmp`)).toBe(false); + expect(collected.summary[0]?.split("\n").slice(0, 8)).toEqual([ + "## github-settings-as-code (snapshot, 2 repositories)", + "", + `1 of 2 snapshots written under ${cfg.snapshotDir}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + "| o/a | remote | :x: failed | - |", + `| o/b | remote | :white_check_mark: snapshot | ${fileB} |`, + ]); }); }); @@ -498,6 +508,41 @@ describe("runSnapshot, dir form", () => { }); }); + test("a fleet whose every target fails writes nothing and the summary says so", async () => { + // No labels route: the read answers 404, the denial that fails the target under the fail policy. + const api = new MockApi({}); + const cfg = dirCfg({ reposInput: "o/a", privateRepos: "show" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(cfg.snapshotDir)).toBe(false); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "failed", + "repos-result": JSON.stringify({ + "o/a": { result: "failed", source: "remote", skippedSections: [] }, + }), + }); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot, 1 repositories)", + "", + `No snapshot was written under ${cfg.snapshotDir}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + "| o/a | remote | :x: failed | - |", + "", + "### o/a (failed)", + "", + "the snapshot failed, so no file was written", + "", + "| Section | Status | Detail |", + "|---|---|---|", + `| labels | :x: failed | the token was denied GET /repos/o/a/labels: 404 Not Found (a 404 here can also mean the resource does not exist). To fix, grant "Issues" (read and write) under the PAT's Repository permissions |`, + ].join("\n"), + ]); + }); + test("a fleet that resolves to no targets is fatal before any file is written", async () => { const empty = join(dir, "repos"); mkdirSync(empty);