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/README.md b/README.md index 4b01a4c90..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 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..c586afd5b --- /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. 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 + +| 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..56010fd50 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; `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/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/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..8475b0476 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,9 @@ 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/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..b18299c60 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. @@ -13,6 +13,7 @@ import { INPUT_DECLS, type Io, type Mode, + type MustBeNever, parseConfig, type RunConfig, } from "../index.js"; @@ -29,7 +30,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 +45,22 @@ 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>; + +/** Compile-time lockstep: a Mode without a subcommand fails here. */ +type _UnlistedMode = MustBeNever>; interface Globals { readonly token?: string; diff --git a/src/flows/inputs.ts b/src/flows/inputs.ts index 0e737b48e..397139375 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]; @@ -369,7 +405,14 @@ type _UnlistedLayering = MustBeNever { const sectionInputs = ["required-sections", "sections"] as const; @@ -420,7 +463,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 +512,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 +536,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 +601,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 +805,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..f968d6f14 --- /dev/null +++ b/src/flows/snapshot.ts @@ -0,0 +1,431 @@ +/** + * 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, 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"; +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 { + writeReplacing( + 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, + }; +} + +/** + * 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 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 { + 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. + 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 + * 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..8f5fae84c 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,54 @@ 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 written = views.filter((view) => view.file !== undefined).length; + const lines = [ + `## github-settings-as-code (snapshot, ${views.length} repositories)`, + "", + 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 |", + "|---|---|---|---|", + ]; + 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..9cd7d0fbf 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, @@ -65,11 +69,15 @@ export { type InputName, type InputReader, MERGE_INPUTS, + MERGE_ONLY_INPUTS, MERGE_REJECTED_INPUTS, MODES, type Mode, parseConfig, type RunConfig, + SNAPSHOT_INPUTS, + SNAPSHOT_ONLY_INPUTS, + SNAPSHOT_REJECTED_INPUTS, } from "./flows/inputs.js"; export { foldLayers, readLayerFiles } from "./flows/layers.js"; export { @@ -77,10 +85,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 +112,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..94814869a 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, 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'; + 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..bc666b538 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,14 +56,20 @@ 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( - (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 2533a59af..32fb59acd 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -805,3 +805,98 @@ 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, check, or merge step they belong to", + "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/cli/inputs.test.ts b/test/cli/inputs.test.ts index e4c8595ad..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, @@ -70,6 +71,71 @@ function cases(): Case[] { inputs: { mode: "check", repository: "o/r", "settings-file": "s.yml" }, env: { GITHUB_TOKEN: "ghp_env" }, }, + { + name: "snapshot, one repository to a file with an allowlist", + argv: [ + "snapshot", + "--token", + "ghp_flag", + "--repository", + "o/r", + "--snapshot-file", + "snap.yml", + "--sections", + "labels", + "--on-missing-permission", + "warn", + ], + inputs: { + mode: "snapshot", + token: "ghp_flag", + repository: "o/r", + "snapshot-file": "snap.yml", + sections: "labels", + "on-missing-permission": "warn", + }, + }, + { + name: "snapshot, the fleet to a directory with a discovery filter", + argv: [ + "snapshot", + "--token", + "ghp_flag", + "--repos", + "*", + "--snapshot-dir", + "snaps", + "--forks", + "exclude", + ], + inputs: { + mode: "snapshot", + token: "ghp_flag", + repos: "*", + "snapshot-dir": "snaps", + forks: "exclude", + }, + env: { GITHUB_REPOSITORY: "o/admin" }, + }, + { + name: "snapshot, a merge-only flag is unknown to the subcommand", + argv: [ + "snapshot", + "--token", + "ghp_flag", + "--snapshot-file", + "snap.yml", + "--layering", + "replace", + ], + inputs: { + mode: "snapshot", + token: "ghp_flag", + "snapshot-file": "snap.yml", + layering: "replace", + }, + unknownFlag: "layering", + }, { name: "check, the workflow's own repository from GITHUB_REPOSITORY", argv: ["check", "--token", "ghp_flag"], @@ -294,7 +360,7 @@ describe("argv -> 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 +371,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 +406,34 @@ 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", + ...FILTER_INPUTS, +]); + +/** 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 +445,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 +461,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 +494,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 +557,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/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.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 b32597785..0ec56774e 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,52 @@ 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; +} + +/** + * 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 ?? {}; @@ -271,8 +315,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 +326,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 +572,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 +712,41 @@ 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 check = await invoke( + { ...scenario, inputs: snapshotCheckInputs(scenario.inputs ?? {}) }, + 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..14355bd39 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,97 @@ 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.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)); + }); + + 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..1361849e7 --- /dev/null +++ b/test/flows/snapshot.test.ts @@ -0,0 +1,563 @@ +/** + * 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, + 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"; +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 }, +}); + +/** 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(() => { + 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("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])); + const cfg = fileCfg(); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const written = readFileSync(cfg.snapshotFile, "utf8"); + 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" }); + 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"); + 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); + }); +}); + +/** 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 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}: ${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 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).toEqual({ "skipped-sections": "", result: "failed" }); + expect(collected.lines).toEqual([ + unwritable( + cfg.snapshotFile, + "snapshot-file", + `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); + expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); + 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 } }, + "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", + `Error: EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`, + ); + 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)); + 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} |`, + ]); + }); +}); + +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" }), + disjointRefusal("./repos", "repos"), + ], + [ + "snapshot-dir above the repos-dir, where a bare .yml would be overwritten", + () => dirCfg({ snapshotDir: "central", reposDir: "central/acme" }), + 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" }), + 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" }), + disjointRefusal("central/snapshots", "central"), + ], + ])("%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(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([]); + 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 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); + 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..ffaad207f 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", @@ -63,6 +64,7 @@ describe("the public entry", () => { "MARKER_LABEL", "MARKER_LABEL_CONFIG", "MERGE_INPUTS", + "MERGE_ONLY_INPUTS", "MERGE_REJECTED_INPUTS", "MERGE_RESULT", "MODES", @@ -86,6 +88,7 @@ describe("the public entry", () => { "REPO_RESULTS", "RERUN_ADVICE", "RemoteTarget", + "RenderableSnapshot", "RepoRef", "RepoResult", "RepoRunOptions", @@ -93,11 +96,17 @@ describe("the public entry", () => { "RepoRunResult", "RepoVisibility", "ReportInput", + "ResolvedTargets", "Route", "RunConfig", "RunFlowConfig", "SECTIONS", "SECTION_KEYS", + "SNAPSHOT_INPUTS", + "SNAPSHOT_ONLY_INPUTS", + "SNAPSHOT_REJECTED_INPUTS", + "SNAPSHOT_RESULTS", + "SNAPSHOT_SCHEMA_URL", "SectionKey", "SectionMeta", "SectionModule", @@ -109,9 +118,16 @@ describe("the public entry", () => { "SettingsProblem", "SingleConfig", "SingleOutcome", + "SnapshotConfig", + "SnapshotLibraryOptions", + "SnapshotReport", + "SnapshotResult", + "SnapshotRunResult", + "SnapshotTargetView", "TaggedEndpoint", "Target", "TargetOutcome", + "TargetsConfig", "Tolerance", "TopLevelShape", "TraceIo", @@ -132,6 +148,7 @@ describe("the public entry", () => { "composeReport", "concludeMerge", "concludeRun", + "concludeSnapshot", "createVisibilityResolver", "dedupeTargets", "deliverArtifactReport", @@ -166,16 +183,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..bd4aa4ae0 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, check, or merge step they belong to", + ], + "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; } 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", ); }); });