From 8aedace9c769f0e0354e44ea4c6b3aafa298da7e Mon Sep 17 00:00:00 2001 From: AlexeyShalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:59:59 +0300 Subject: [PATCH] feat(report): status/v1 and check/v1 schemas; a compatibility page The JSON reports of status, apply and check now carry a schema field, status/v1 and check/v1, and have JSON Schemas (touchmark schema status, touchmark schema check), with tests that validate them against samples and every golden report and keep their states, actions and opt_in values equal to Go's. The fields are added; nothing else in the reports changes. docs/reference/compatibility.md says what a hub and a script can rely on at 0.x: configuration files at version 1 change only by adding, versioned reports only by adding fields and values, what touchmark leaves in repositories stays readable (the marker's payload is frozen), and the command line, exit codes, variables, report file names, Action inputs and image are stable; text output and messages are not. It lists the two deprecated keys, which stay, with a warning. Also: no PyPI release (the section on adding it later is gone), and the text that still spoke of 1.0 points to the compatibility page. --- CONTRIBUTING.md | 10 +- docs/project/release.md | 13 +- docs/reference/commands.md | 2 +- docs/reference/compatibility.md | 64 ++++++ docs/reference/hub.md | 2 +- docs/reference/output.md | 88 +++++++- internal/cli/check.go | 1 + internal/cli/cli.go | 4 +- internal/cli/flags.go | 2 +- internal/cli/sync.go | 1 + .../cli/testdata/golden/assumed/apply.json | 1 + .../testdata/golden/check-defects/check.json | 1 + .../golden/crlf-committed/apply-adopt.json | 1 + .../testdata/golden/crlf-committed/apply.json | 1 + internal/cli/testdata/golden/crlf/apply.json | 1 + .../testdata/golden/file-directory/apply.json | 1 + .../golden/file-directory/status.json | 1 + .../cli/testdata/golden/formerly/apply.json | 1 + .../golden/fresh-opt-in/apply-again.json | 1 + .../testdata/golden/fresh-opt-in/apply.json | 1 + .../testdata/golden/fresh-opt-in/status.json | 1 + .../testdata/golden/ignore-subtree/apply.json | 1 + .../cli/testdata/golden/in-sync/apply.json | 1 + .../incomplete-selection/apply-packs.json | 1 + .../golden/incomplete-selection/status.json | 1 + .../cli/testdata/golden/legacy/apply.json | 1 + .../golden/local-edit/apply-adopt.json | 1 + .../cli/testdata/golden/local-edit/apply.json | 1 + .../testdata/golden/not-opted-in/apply.json | 1 + .../cli/testdata/golden/opted-out/apply.json | 1 + .../cli/testdata/golden/orphaned/apply.json | 1 + .../cli/testdata/golden/outdated/apply.json | 1 + .../testdata/golden/pack-override/apply.json | 1 + .../golden/plain-directory/apply.json | 1 + .../cli/testdata/golden/requires/apply.json | 1 + .../cli/testdata/golden/retire/apply.json | 1 + .../testdata/golden/tiny-version/apply.json | 1 + .../golden/worktree/apply-worktree.json | 1 + internal/report/report.go | 12 +- internal/report/status_schema_test.go | 199 +++++++++++++++++ schemas/check.schema.json | 51 +++++ schemas/embed.go | 14 +- schemas/hub.schema.json | 2 +- schemas/status.schema.json | 208 ++++++++++++++++++ zensical.toml | 1 + 45 files changed, 670 insertions(+), 32 deletions(-) create mode 100644 docs/reference/compatibility.md create mode 100644 internal/report/status_schema_test.go create mode 100644 schemas/check.schema.json create mode 100644 schemas/status.schema.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51551fa..d38500b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -169,11 +169,11 @@ The first push creates the `ghcr.io/bedrock-python/touchmark` package private; m public in the organisation's package settings once, or every `docker pull`, every GitLab and Gitea job and the Action are denied. -- `feat:` bumps the minor version, `fix:` the patch version, `feat!:` / a `BREAKING CHANGE:` - footer bumps the major version (before 1.0 a breaking change bumps the minor version — - `bump-minor-pre-major` is on). -- To force a specific version, add a `Release-As: x.y.z` footer to a commit — this is how - 1.0.0 is cut. +- `feat:` bumps the minor version, `fix:` the patch version. touchmark is at 0.x, where a + breaking change (`feat!:`, a `BREAKING CHANGE:` footer) bumps the minor version too + (`bump-minor-pre-major` is on). What counts as breaking is on the + [Compatibility](https://bedrock-python.github.io/touchmark/reference/compatibility/) page. +- To force a specific version, add a `Release-As: x.y.z` footer to a commit. - Tags are `vX.Y.Z`, without the component prefix the other bedrock-python repositories use: Go modules and the Action resolve bare `v` tags only. - release-please writes each release above the first version heading of `CHANGELOG.md`. diff --git a/docs/project/release.md b/docs/project/release.md index db8c842..50edcdb 100644 --- a/docs/project/release.md +++ b/docs/project/release.md @@ -44,7 +44,7 @@ Every job after `check` checks out the tag's commit by its id and stops if the c **The vulnerability scan.** The scan that blocks is CI's: the job `release-snapshot` builds the image a release would publish and scans it on every pull request, the release pull request included, and every night, so a new vulnerability in the base image shows up before a release does. The scan in `publish.yml` only reports: by then the tag and the release exist, the same base image is in every earlier release, and holding the image back would leave the Action at the tag broken for good. -**Versions.** `feat:` bumps the minor version, `fix:` the patch version; before 1.0 a breaking change (`feat!:`, a `BREAKING CHANGE:` footer) bumps the minor version too (`bump-minor-pre-major`). A `Release-As: x.y.z` footer on a commit forces a version. The manifest starts at `0.0.0`, so the first release, from the commit `feat: initial release`, is `0.1.0`. +**Versions.** `feat:` bumps the minor version, `fix:` the patch version; touchmark is at 0.x, where a breaking change (`feat!:`, a `BREAKING CHANGE:` footer) bumps the minor version too (`bump-minor-pre-major`). What counts as breaking is in [Compatibility](../reference/compatibility.md). A `Release-As: x.y.z` footer on a commit forces a version. The manifest starts at `0.0.0`, so the first release, from the commit `feat: initial release`, is `0.1.0`. **Tags.** A release tag is `vX.Y.Z`, without the component prefix of the org's libraries (`pg-partsmith-v1.7.2`): the Go module proxy and `uses: bedrock-python/touchmark@vX.Y.Z` need bare semver tags (`include-component-in-tag: false`). The release is named `vX.Y.Z` too. @@ -110,7 +110,7 @@ touchmark adds nothing stricter to them on purpose: no required approval (a main 5. **The first release pull request.** `CHANGELOG.md` has no release yet, so Release Please writes `# Changelog`, the 0.1.0 section, and then the file's own header, demoted to `## Changelog`. Before merging, edit `CHANGELOG.md` in the pull request: the header back on top, the 0.1.0 section below it. Later releases go in above the previous one. 6. **The package.** The first Publish run creates `ghcr.io/bedrock-python/touchmark`, linked to the repository by the index's `org.opencontainers.image.source`. With public packages allowed in the organisation, it takes the public repository's visibility: v0.1.0's package was public from its first push, and `verify` passed on the first run. Should a package come out private, `verify` fails at the anonymous steps: in the package's settings, check the link to the repository and that the repository's Actions have write access, then **Change visibility → Public**, and re-run the failed jobs. Hubs and the Action pull anonymously. -PyPI is not part of a release yet: see [Adding PyPI later](#adding-pypi-later). +touchmark is not published to PyPI: a release is the binaries, the image and the Action. ## Release, step by step @@ -202,12 +202,3 @@ docker buildx imagetools inspect ghcr.io/bedrock-python/touchmark@sha256: ```text -Usage: touchmark schema hub|targets|opt-in|operations|report|doctor|setup +Usage: touchmark schema hub|targets|opt-in|operations|report|doctor|setup|status|check Print the JSON Schema of a configuration file or of a report. ``` diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md new file mode 100644 index 0000000..2a8e1e0 --- /dev/null +++ b/docs/reference/compatibility.md @@ -0,0 +1,64 @@ +# Compatibility + +touchmark is at 0.x and stays there for now. This page says what a hub, a target and a +script around touchmark can rely on from one release to the next. + +## Versions + +A patch release (`fix:`) breaks nothing listed below. A minor release (`feat:`) adds +things. Before 1.0, a breaking change also bumps the minor version (`feat!:` or a +`BREAKING CHANGE:` footer), and the changelog lists it under **⚠ BREAKING CHANGES**. +A hub pins touchmark to a release (the template pins the Action by commit and the image by +digest), so a new minor version arrives as a pull request: read that section of the +changelog before you merge it. + +## Stable + +- **Configuration files at `version: 1`:** `hub.yml`, `targets.yml`, the opt-in file and + `.touchmark/operations.yml`. A file that a release accepts keeps working, with the same + meaning, in every later release that reads `version: 1`. Changes only add: new optional + keys and new values. An older release rejects a key it does not know, so a new key needs + the release that added it; the reference says which release that is. The opt-in file + lives in repositories the hub does not control, so it gains keys rarely. Removing a key + or changing what it means takes a `version: 2` of that file, and touchmark then keeps + reading `version: 1`, with a warning. +- **Versioned JSON reports:** `report/v1` (`plan`, `distribute`, and the lines of + `--stream`), `doctor/v1`, `setup/v1`, `status/v1` (`status`, `apply`) and `check/v1`. + Changes only add: new fields, and new values where a field lists its values (outcomes, + reasons, states, check and step names). Ignore fields you do not know and treat values + you do not know as opaque. Anything else is a new version, such as `report/v2`, released + as a breaking change. Validate a report against the schema of the release that wrote it + (`touchmark schema `): the schemas published on `master` describe the next release + and reject fields they do not know. +- **What touchmark leaves in repositories:** the marker in pull request bodies + (`touchmark:v1`), the commit trailers (`Touchmark-Hub`, `Touchmark-Stream`, + `Touchmark-Content`, `Touchmark-Hub-Commit`), the control lines of a pull request body, + the marker of the plan comment, and the sync branch's name. Every release reads what an + earlier one wrote. The marker's payload is frozen: a change to it is a `touchmark:v2` + marker, which releases read alongside `v1`. +- **The command line and CI:** command and flag names, flag defaults, exit codes, + environment variable names, the names of the report files in CI, the GitHub Action's + inputs, and the image's tags (`X.Y.Z`, `X.Y`, `latest`) and what the hub template's jobs + use in it (`sh`, `touchmark` on the `PATH`, user 65532). + +## Not stable + +Text and Markdown output, step summaries and annotations, the wording of pull request +bodies and comments, warnings and error messages, the output of `manifest` and `migrate`, +and the Go packages, `schemas` included. Parse the JSON reports, not the text. + +## Deprecation + +A deprecated key, flag or input is marked deprecated in its schema and in the reference, +with what replaces it. Using it warns: in `check`, and in the warnings of `status`, `plan` +and `distribute`, which reach the hub's pull request comment and the step summary. The +changelog announces the deprecation. A flag or an input keeps working for at least two +minor releases and six months; a configuration key keeps working for as long as touchmark +reads that version of its file. Removing either is a breaking change. + +Deprecated now: + +| What | Since | Instead | +|---|---|---| +| `commit.author` in `hub.yml` | before 0.1.0 | Nothing: the writer account authors every commit. The key is ignored, with a warning. | +| a bare `repos:` list in `targets.yml` | before 0.1.0 | `version: 1` with `targets:` entries. The list is read as `repo:` entries, with a warning. | diff --git a/docs/reference/hub.md b/docs/reference/hub.md index aa07484..1e56ae2 100644 --- a/docs/reference/hub.md +++ b/docs/reference/hub.md @@ -84,7 +84,7 @@ In a hub pull request, `providers` and `ca_file` are read from the default branc | `opt_in_file` | string | Path of the opt-in file in targets. Default: .engineering-assets.yml. | | `commit` | object | Commit settings. The commit author is always the provider's writer account. | | `commit.message` | string | Commit message. Default: "chore: sync engineering assets". Must not start with Draft: or WIP:, which turns a GitLab merge request into a draft. | -| `commit.author` | object | Deprecated. Removed in v1 and ignored with a warning: commits are always authored by the writer account. | +| `commit.author` | object | Deprecated. Ignored with a warning: commits are always authored by the writer account. | | `commit.author.name` | string | — | | `commit.author.email` | string | — | | `pr` | object | Pull request settings. After creation, the title, draft state and labels belong to people. | diff --git a/docs/reference/output.md b/docs/reference/output.md index b164229..43e7b25 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -9,7 +9,11 @@ Every command that inspects targets prints a report on stdout: text by default, | `report/v1` | `plan`, `distribute` | `touchmark schema report` | `touchmark-report.json`, `touchmark-report.md`; `distribute` also `touchmark-report.jsonl` | | `doctor/v1` | `doctor` | `touchmark schema doctor` | `touchmark-doctor.json`, `touchmark-doctor.md` | | `setup/v1` | `setup` | `touchmark schema setup` | — | -| status | `status`, `apply` | — | — | +| `status/v1` | `status`, `apply` | `touchmark schema status` | — | +| `check/v1` | `check` | `touchmark schema check` | — | + +What may change in these formats between releases, and what may not, is in +[Compatibility](compatibility.md). `--report FILE` (`distribute`, `doctor`) also writes the JSON report to a file; `--stream FILE` (`distribute`) writes one JSON line per finished target as the run goes, @@ -279,6 +283,7 @@ pack selection and where each pack came from, one entry per managed path with it ```json { + "schema": "status/v1", "command": "status", "dry_run": false, "hub": { "dir": "/src/engineering-assets", "id": "acme-eng", "commit": "ffae0953c140298cf045e600f15710104e9d5d0d" }, @@ -295,5 +300,82 @@ pack selection and where each pack came from, one entry per managed path with it } ``` -This format has no schema yet and may change before 1.0; the `report/v1`, `doctor/v1` and -`setup/v1` formats change only with their version. +`apply` adds `results`, one per entry whose action is not `keep`, with its `outcome` +(`done`, `skipped`, `failed`); `apply --dry-run` has none. + + + +| Key | Type | Description | +|---|---|---| +| `schema` | `status/v1`, required | Report format: status/v1. | +| `command` | one of `status`, `apply`, required | — | +| `dry_run` | boolean, required | apply --dry-run: the plan only, nothing written. Always false for status. | +| `hub` | object, required | The hub the report was made from. | +| `hub.dir` | string, required | The hub's directory. | +| `hub.id` | string, required | hub.yml's id; empty for a hub without hub.yml. | +| `hub.commit` | string, required | The hub commit read; empty when touchmark stopped before reading it. | +| `target` | object, required | The target working tree. | +| `target.root` | string, required | The target's working tree. | +| `target.ref` | string, required | The target in targets.yml, path or provider:path; empty when it could not be determined. | +| `target.opt_in_file` | string, required | The opt-in file's path in the target. | +| `target.opted_in` | boolean, required | The target gets packs: opt_in is file, assumed or flag. | +| `target.opt_in` | one of `file`, `assumed`, `flag`, `opted-out`, `none`, required | Why the target is opted in or not. file: the opt-in file is there; assumed: there is none, and a repo: entry with opt_in: assumed subscribes it; flag: there is none, and --assume-opt-in counts it as opted in; opted-out: the file says enabled: false; none: neither. | +| `selection` | object, required | The resolved pack list; null when the target has not opted in. | +| `selection.packs` | list of strings, required | The packs the target gets, requires included. | +| `selection.complete` | boolean, required | Every pack named was found in the hub. | +| `selection.unresolved` | list of strings, required | Packs named that the hub does not have. | +| `selection.sources` | map of name to list of strings, required | Per pack, where it was selected: the opt-in file, targets.yml, defaults or requires <pack>. | +| `entries` | list of objects, required | The whole plan in plan order, one entry per managed path, current entries included. | +| `entries[].path` | string, not empty, required | The path in the target. | +| `entries[].state` | one of `missing`, `current`, `outdated`, `local`, `ignored`, `retired`, `retired-local`, `unsafe`, `orphaned`, required | The file's state in the target. | +| `entries[].action` | one of `keep`, `create`, `update`, `delete`, `adopt`, `chmod`, required | What apply does with it. | +| `entries[].pack` | string, required | The pack that ships the path, or shipped it. | +| `entries[].from` | string, required | The blob id the target has; empty when it has none. | +| `entries[].to` | string, required | The blob id apply writes; empty for keep and delete. | +| `entries[].mode` | one of ``, `100644`, `100755`, required | The file mode apply writes; empty when it writes nothing. | +| `entries[].detail` | string, required | Why, for states and actions that need a reason. | +| `entries[].after_deletes` | boolean, required | Written after the deletes, because a path it needs is deleted first. | +| `results` | list of objects | apply without --dry-run only: what it did with each entry whose action is not keep, in execution order. | +| `results[].path` | string, not empty, required | — | +| `results[].action` | one of `keep`, `create`, `update`, `delete`, `adopt`, `chmod`, required | — | +| `results[].state` | one of `missing`, `current`, `outdated`, `local`, `ignored`, `retired`, `retired-local`, `unsafe`, `orphaned`, required | — | +| `results[].outcome` | one of `done`, `skipped`, `failed`, required | — | +| `results[].detail` | string, required | The reason for skipped and the error for failed. | +| `summary` | object, required | Entries per state, the changes (entries whose action is not keep) and, for apply, the outcomes. | +| `summary.missing` | integer ≥ 0, required | — | +| `summary.current` | integer ≥ 0, required | — | +| `summary.outdated` | integer ≥ 0, required | — | +| `summary.local` | integer ≥ 0, required | — | +| `summary.ignored` | integer ≥ 0, required | — | +| `summary.retired` | integer ≥ 0, required | — | +| `summary.retired_local` | integer ≥ 0, required | — | +| `summary.unsafe` | integer ≥ 0, required | — | +| `summary.orphaned` | integer ≥ 0, required | — | +| `summary.changes` | integer ≥ 0, required | — | +| `summary.done` | integer ≥ 0, required | — | +| `summary.skipped` | integer ≥ 0, required | — | +| `summary.failed` | integer ≥ 0, required | — | +| `warnings` | list of strings, required | — | + + + +## check + +`--format json` prints the hub, its packs, and every error and warning; `errors` is empty +when the hub passes. + + + +| Key | Type | Description | +|---|---|---| +| `schema` | `check/v1`, required | Report format: check/v1. | +| `command` | `check`, required | — | +| `hub` | object, required | The hub checked. | +| `hub.dir` | string, required | The hub's directory. | +| `hub.id` | string, required | hub.yml's id; empty for a hub without hub.yml. | +| `hub.commit` | string, required | The hub commit read; empty when touchmark stopped before reading it. | +| `packs` | list of strings, required | The hub's packs. | +| `errors` | list of strings, required | What makes check exit 2; empty when the hub passes. | +| `warnings` | list of strings, required | — | + + diff --git a/internal/cli/check.go b/internal/cli/check.go index 112d44d..7f35956 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -29,6 +29,7 @@ func runCheck(ctx context.Context, e *env, o *options) error { return err } c := &checkRun{hub: h, rep: &report.Check{ + Schema: report.CheckSchema, Command: "check", Packs: []string{}, Errors: []string{}, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c609b54..2f41c39 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,7 +14,7 @@ // touchmark status [--hub DIR] [--dir DIR] [--repo REF] [--packs a,b] [--worktree] [--format text|json] // touchmark apply [same as status] [--adopt GLOB]... [--dry-run] // touchmark manifest [--hub DIR] [--format json] -// touchmark schema hub|targets|opt-in|operations|report|doctor|setup +// touchmark schema hub|targets|opt-in|operations|report|doctor|setup|status|check // touchmark version // // --hub defaults to $TOUCHMARK_HUB; --dir defaults to the current directory. @@ -102,7 +102,7 @@ func commands() []*command { {name: "manifest", synopsis: "[--hub DIR] [--format json]", summary: "print the ownership manifest built from the hub's history", flags: manifestFlags, run: runManifest}, - {name: "schema", synopsis: "hub|targets|opt-in|operations|report|doctor|setup", + {name: "schema", synopsis: "hub|targets|opt-in|operations|report|doctor|setup|status|check", summary: "print the JSON Schema of a configuration file or of a report", flags: schemaFlags, run: runSchema}, {name: "version", synopsis: "", diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 605223b..4761feb 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -209,7 +209,7 @@ func manifestFlags() (*flagSet, *options) { func schemaFlags() (*flagSet, *options) { f, o := newFlagSet("schema") - f.nargs, f.argName = 1, "schema name: hub, targets, opt-in, operations, report, doctor or setup" + f.nargs, f.argName = 1, "schema name: hub, targets, opt-in, operations, report, doctor, setup, status or check" return f, o } diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 0ae13a3..eba4dcf 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -44,6 +44,7 @@ func runSync(ctx context.Context, e *env, o *options, write bool) error { return err } s.rep = &report.Sync{ + Schema: report.SyncSchema, Command: "status", Hub: s.hub.report(), Target: s.tgt.report(), diff --git a/internal/cli/testdata/golden/assumed/apply.json b/internal/cli/testdata/golden/assumed/apply.json index e8e542a..ff848b5 100644 --- a/internal/cli/testdata/golden/assumed/apply.json +++ b/internal/cli/testdata/golden/assumed/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/check-defects/check.json b/internal/cli/testdata/golden/check-defects/check.json index 58b8094..4054796 100644 --- a/internal/cli/testdata/golden/check-defects/check.json +++ b/internal/cli/testdata/golden/check-defects/check.json @@ -1,4 +1,5 @@ { + "schema": "check/v1", "command": "check", "hub": { "dir": "$HUB", diff --git a/internal/cli/testdata/golden/crlf-committed/apply-adopt.json b/internal/cli/testdata/golden/crlf-committed/apply-adopt.json index 75c2edb..48f5922 100644 --- a/internal/cli/testdata/golden/crlf-committed/apply-adopt.json +++ b/internal/cli/testdata/golden/crlf-committed/apply-adopt.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/crlf-committed/apply.json b/internal/cli/testdata/golden/crlf-committed/apply.json index aacac75..b9d5910 100644 --- a/internal/cli/testdata/golden/crlf-committed/apply.json +++ b/internal/cli/testdata/golden/crlf-committed/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/crlf/apply.json b/internal/cli/testdata/golden/crlf/apply.json index 30f9bec..5dafad0 100644 --- a/internal/cli/testdata/golden/crlf/apply.json +++ b/internal/cli/testdata/golden/crlf/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/file-directory/apply.json b/internal/cli/testdata/golden/file-directory/apply.json index 4c04a25..0a8a24d 100644 --- a/internal/cli/testdata/golden/file-directory/apply.json +++ b/internal/cli/testdata/golden/file-directory/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/file-directory/status.json b/internal/cli/testdata/golden/file-directory/status.json index 80553bb..e8e2f6b 100644 --- a/internal/cli/testdata/golden/file-directory/status.json +++ b/internal/cli/testdata/golden/file-directory/status.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "status", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/formerly/apply.json b/internal/cli/testdata/golden/formerly/apply.json index 8a1a6fa..aa0e6c7 100644 --- a/internal/cli/testdata/golden/formerly/apply.json +++ b/internal/cli/testdata/golden/formerly/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/fresh-opt-in/apply-again.json b/internal/cli/testdata/golden/fresh-opt-in/apply-again.json index cb43382..fb8f26e 100644 --- a/internal/cli/testdata/golden/fresh-opt-in/apply-again.json +++ b/internal/cli/testdata/golden/fresh-opt-in/apply-again.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/fresh-opt-in/apply.json b/internal/cli/testdata/golden/fresh-opt-in/apply.json index aecc57d..6833fcc 100644 --- a/internal/cli/testdata/golden/fresh-opt-in/apply.json +++ b/internal/cli/testdata/golden/fresh-opt-in/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/fresh-opt-in/status.json b/internal/cli/testdata/golden/fresh-opt-in/status.json index 57d3362..ebdd645 100644 --- a/internal/cli/testdata/golden/fresh-opt-in/status.json +++ b/internal/cli/testdata/golden/fresh-opt-in/status.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "status", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/ignore-subtree/apply.json b/internal/cli/testdata/golden/ignore-subtree/apply.json index 9ca877b..8ec1bd3 100644 --- a/internal/cli/testdata/golden/ignore-subtree/apply.json +++ b/internal/cli/testdata/golden/ignore-subtree/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/in-sync/apply.json b/internal/cli/testdata/golden/in-sync/apply.json index cbfc415..cb12844 100644 --- a/internal/cli/testdata/golden/in-sync/apply.json +++ b/internal/cli/testdata/golden/in-sync/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/incomplete-selection/apply-packs.json b/internal/cli/testdata/golden/incomplete-selection/apply-packs.json index 786bd06..7eb3cc6 100644 --- a/internal/cli/testdata/golden/incomplete-selection/apply-packs.json +++ b/internal/cli/testdata/golden/incomplete-selection/apply-packs.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/incomplete-selection/status.json b/internal/cli/testdata/golden/incomplete-selection/status.json index 8145e13..b9d456f 100644 --- a/internal/cli/testdata/golden/incomplete-selection/status.json +++ b/internal/cli/testdata/golden/incomplete-selection/status.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "status", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/legacy/apply.json b/internal/cli/testdata/golden/legacy/apply.json index afe0c79..e99937d 100644 --- a/internal/cli/testdata/golden/legacy/apply.json +++ b/internal/cli/testdata/golden/legacy/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/local-edit/apply-adopt.json b/internal/cli/testdata/golden/local-edit/apply-adopt.json index 6a5be93..8771daa 100644 --- a/internal/cli/testdata/golden/local-edit/apply-adopt.json +++ b/internal/cli/testdata/golden/local-edit/apply-adopt.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/local-edit/apply.json b/internal/cli/testdata/golden/local-edit/apply.json index 1ec5986..4306861 100644 --- a/internal/cli/testdata/golden/local-edit/apply.json +++ b/internal/cli/testdata/golden/local-edit/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/not-opted-in/apply.json b/internal/cli/testdata/golden/not-opted-in/apply.json index 3659133..d0746c0 100644 --- a/internal/cli/testdata/golden/not-opted-in/apply.json +++ b/internal/cli/testdata/golden/not-opted-in/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/opted-out/apply.json b/internal/cli/testdata/golden/opted-out/apply.json index f0e0588..0068555 100644 --- a/internal/cli/testdata/golden/opted-out/apply.json +++ b/internal/cli/testdata/golden/opted-out/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/orphaned/apply.json b/internal/cli/testdata/golden/orphaned/apply.json index a1ec4d0..7a589be 100644 --- a/internal/cli/testdata/golden/orphaned/apply.json +++ b/internal/cli/testdata/golden/orphaned/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/outdated/apply.json b/internal/cli/testdata/golden/outdated/apply.json index 464e26e..b3bc188 100644 --- a/internal/cli/testdata/golden/outdated/apply.json +++ b/internal/cli/testdata/golden/outdated/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/pack-override/apply.json b/internal/cli/testdata/golden/pack-override/apply.json index f4136ea..c0abe55 100644 --- a/internal/cli/testdata/golden/pack-override/apply.json +++ b/internal/cli/testdata/golden/pack-override/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/plain-directory/apply.json b/internal/cli/testdata/golden/plain-directory/apply.json index b95cbb6..55523fe 100644 --- a/internal/cli/testdata/golden/plain-directory/apply.json +++ b/internal/cli/testdata/golden/plain-directory/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/requires/apply.json b/internal/cli/testdata/golden/requires/apply.json index 88b3186..60dcda9 100644 --- a/internal/cli/testdata/golden/requires/apply.json +++ b/internal/cli/testdata/golden/requires/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/retire/apply.json b/internal/cli/testdata/golden/retire/apply.json index 6c3cade..f9e2695 100644 --- a/internal/cli/testdata/golden/retire/apply.json +++ b/internal/cli/testdata/golden/retire/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/tiny-version/apply.json b/internal/cli/testdata/golden/tiny-version/apply.json index 58750e7..36de9d1 100644 --- a/internal/cli/testdata/golden/tiny-version/apply.json +++ b/internal/cli/testdata/golden/tiny-version/apply.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/cli/testdata/golden/worktree/apply-worktree.json b/internal/cli/testdata/golden/worktree/apply-worktree.json index 99eb4e9..1ad2ad3 100644 --- a/internal/cli/testdata/golden/worktree/apply-worktree.json +++ b/internal/cli/testdata/golden/worktree/apply-worktree.json @@ -1,4 +1,5 @@ { + "schema": "status/v1", "command": "apply", "dry_run": false, "hub": { diff --git a/internal/report/report.go b/internal/report/report.go index 55c1bf8..e404a69 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,7 +2,9 @@ // apply) and renders it as text or JSON. // // JSON field names are snake_case and stable: every field is always present, -// except "results", which only apply prints. +// except "results", which only apply prints. The JSON Schemas are +// schemas/status.schema.json (status/v1) and schemas/check.schema.json +// (check/v1). package report import ( @@ -14,6 +16,12 @@ import ( "github.com/bedrock-python/touchmark/internal/decide" ) +// SyncSchema and CheckSchema are the values of the reports' schema field. +const ( + SyncSchema = "status/v1" + CheckSchema = "check/v1" +) + // Outcomes of an apply result. const ( Done = "done" @@ -197,6 +205,7 @@ func Summarize(entries []Entry, results []Result) Summary { // Sync is the report of status and apply. type Sync struct { + Schema string `json:"schema"` // SyncSchema Command string `json:"command"` // "status" or "apply" DryRun bool `json:"dry_run"` Hub Hub `json:"hub"` @@ -214,6 +223,7 @@ type Sync struct { // Check is the report of check. type Check struct { + Schema string `json:"schema"` // CheckSchema Command string `json:"command"` // "check" Hub Hub `json:"hub"` Packs []string `json:"packs"` diff --git a/internal/report/status_schema_test.go b/internal/report/status_schema_test.go new file mode 100644 index 0000000..d2520e7 --- /dev/null +++ b/internal/report/status_schema_test.go @@ -0,0 +1,199 @@ +package report + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/bedrock-python/touchmark/internal/decide" + "github.com/bedrock-python/touchmark/schemas" +) + +// compileSchema compiles the embedded schema called name. +func compileSchema(t *testing.T, name string) *jsonschema.Schema { + t.Helper() + data, ok := schemas.Get(name) + if !ok { + t.Fatalf("no %s schema", name) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + url := schemas.BaseURL + name + ".schema.json" + c := jsonschema.NewCompiler() + c.DefaultDraft(jsonschema.Draft2020) + c.AssertFormat() + if err := c.AddResource(url, doc); err != nil { + t.Fatal(err) + } + sch, err := c.Compile(url) + if err != nil { + t.Fatal(err) + } + return sch +} + +// validateJSON encodes v as the commands print it and validates it. +func validateJSON(t *testing.T, sch *jsonschema.Schema, v any) error { + t.Helper() + var buf bytes.Buffer + if err := WriteJSON(&buf, v); err != nil { + t.Fatal(err) + } + doc, err := jsonschema.UnmarshalJSON(&buf) + if err != nil { + t.Fatal(err) + } + return sch.Validate(doc) +} + +func TestStatusSchema(t *testing.T) { + sch := compileSchema(t, "status") + // applied() as the CLI prints it: status and apply never print a null + // list. + ap := applied() + ap.Schema, ap.Target.OptIn, ap.Selection.Unresolved = SyncSchema, OptInFile, []string{} + ap.Entries[0].To, ap.Entries[0].Mode = "da9823e8fe7223ced65fd1d888c4086c5663d588", "100644" + if err := validateJSON(t, sch, ap); err != nil { + t.Errorf("apply: %v", err) + } + none := &Sync{Schema: SyncSchema, Command: "status", Hub: Hub{Dir: "/src/hub"}, + Target: Target{Root: "/src/web", OptIn: OptInNone}, Entries: []Entry{}, Warnings: []string{}} + if err := validateJSON(t, sch, none); err != nil { + t.Errorf("a target not opted in: %v", err) + } + + bad := map[string]func(s *Sync){ + "no schema": func(s *Sync) { s.Schema = "" }, + "another schema": func(s *Sync) { s.Schema = "status/v2" }, + "unknown state": func(s *Sync) { s.Entries[0].State = "stale" }, + "unknown action": func(s *Sync) { s.Entries[0].Action = "move" }, + "unknown opt_in": func(s *Sync) { s.Target.OptIn = "maybe" }, + "a short blob id": func(s *Sync) { s.Entries[0].To = "da9823e" }, + "an unknown mode": func(s *Sync) { s.Entries[0].Mode = "120000" }, + "an unknown result": func(s *Sync) { s.Results[0].Outcome = "partial" }, + } + for name, mutate := range bad { + s := applied() + s.Schema, s.Target.OptIn, s.Selection.Unresolved = SyncSchema, OptInFile, []string{} + mutate(s) + if err := validateJSON(t, sch, s); err == nil { + t.Errorf("%s: valid", name) + } + } +} + +// TestStatusSchemaEnums keeps the states, actions and opt_in values of the +// schema identical to Go's. +func TestStatusSchemaEnums(t *testing.T) { + data, _ := schemas.Get("status") + var doc struct { + Properties struct { + Target struct { + Properties struct { + OptIn struct { + Enum []string `json:"enum"` + } `json:"opt_in"` + } `json:"properties"` + } `json:"target"` + } `json:"properties"` + Defs struct { + State struct { + Enum []string `json:"enum"` + } `json:"state"` + Action struct { + Enum []string `json:"enum"` + } `json:"action"` + } `json:"$defs"` + } + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatal(err) + } + var states []string + for _, c := range (Summary{}).stateCounts() { + states = append(states, string(c.state)) + } + actions := []string{string(decide.Keep), string(decide.Create), string(decide.Update), string(decide.Delete), string(decide.Adopt), string(decide.Chmod)} + optIns := []string{OptInFile, OptInAssumed, OptInFlag, OptInDisabled, OptInNone} + for _, c := range []struct { + name string + schema, inGo []string + }{ + {"states", doc.Defs.State.Enum, states}, + {"actions", doc.Defs.Action.Enum, actions}, + {"opt_in", doc.Properties.Target.Properties.OptIn.Enum, optIns}, + } { + if !sameSet(c.schema, c.inGo) { + t.Errorf("%s: schema %q, Go %q", c.name, c.schema, c.inGo) + } + } +} + +func sameSet(a, b []string) bool { + a, b = slices.Clone(a), slices.Clone(b) + slices.Sort(a) + slices.Sort(b) + return slices.Equal(a, b) +} + +func TestCheckSchema(t *testing.T) { + sch := compileSchema(t, "check") + c := &Check{Schema: CheckSchema, Command: "check", Hub: Hub{Dir: "/src/hub", ID: "acme-eng", Commit: "0123456789abcdef0123456789abcdef01234567"}, + Packs: []string{"base"}, Errors: []string{}, Warnings: []string{}} + if err := validateJSON(t, sch, c); err != nil { + t.Error(err) + } + c.Schema = "" + if err := validateJSON(t, sch, c); err == nil { + t.Error("a check report without schema: valid") + } +} + +// TestLocalGoldensValidate validates the JSON reports of status, apply and +// check that the CLI's end-to-end tests produce +// (internal/cli/testdata/golden) against their schemas, with the hub commit +// their placeholder stands for. +func TestLocalGoldensValidate(t *testing.T) { + schemaOf := map[string]*jsonschema.Schema{"status": compileSchema(t, "status"), "apply": compileSchema(t, "status"), "check": compileSchema(t, "check")} + files, err := filepath.Glob(filepath.Join("..", "cli", "testdata", "golden", "*", "*.json")) + if err != nil { + t.Fatal(err) + } + seen := map[string]int{} + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + var head struct { + Command string `json:"command"` + } + if json.Unmarshal(data, &head) != nil { + continue + } + sch := schemaOf[head.Command] + if sch == nil { + continue + } + seen[head.Command]++ + data = bytes.ReplaceAll(data, []byte("$COMMIT"), []byte("0123456789abcdef0123456789abcdef01234567")) + v, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + if err := sch.Validate(v); err != nil { + t.Errorf("%s: %v", file, err) + } + } + for _, cmd := range []string{"status", "apply", "check"} { + if seen[cmd] == 0 { + t.Errorf("no %s report under ../cli/testdata/golden; update this test if they moved", cmd) + } + } +} diff --git a/schemas/check.schema.json b/schemas/check.schema.json new file mode 100644 index 0000000..ccf71ae --- /dev/null +++ b/schemas/check.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/bedrock-python/touchmark/master/schemas/check.schema.json", + "title": "touchmark check report", + "description": "The JSON report of touchmark check (check/v1): the hub's configuration files and packs, with every error and warning found.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "command", "hub", "packs", "errors", "warnings"], + "properties": { + "schema": { + "description": "Report format: check/v1.", + "const": "check/v1" + }, + "command": { "const": "check" }, + "hub": { + "description": "The hub checked.", + "type": "object", + "additionalProperties": false, + "required": ["dir", "id", "commit"], + "properties": { + "dir": { + "description": "The hub's directory.", + "type": "string" + }, + "id": { + "description": "hub.yml's id; empty for a hub without hub.yml.", + "type": "string" + }, + "commit": { + "description": "The hub commit read; empty when touchmark stopped before reading it.", + "type": "string", + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})?$" + } + } + }, + "packs": { + "description": "The hub's packs.", + "type": "array", + "items": { "type": "string" } + }, + "errors": { + "description": "What makes check exit 2; empty when the hub passes.", + "type": "array", + "items": { "type": "string" } + }, + "warnings": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/schemas/embed.go b/schemas/embed.go index 0433afd..f365010 100644 --- a/schemas/embed.go +++ b/schemas/embed.go @@ -1,6 +1,6 @@ // Package schemas embeds the JSON Schemas (draft 2020-12) of touchmark's -// configuration files, of its delivery report and of the reports of doctor -// and setup, for `touchmark schema` and for tests. +// configuration files and of its JSON reports, for `touchmark schema` and +// for tests. package schemas import "embed" @@ -12,17 +12,19 @@ const BaseURL = "https://raw.githubusercontent.com/bedrock-python/touchmark/mast //go:embed *.json var files embed.FS -var names = []string{"hub", "targets", "opt-in", "operations", "report", "doctor", "setup"} +var names = []string{"hub", "targets", "opt-in", "operations", "report", "doctor", "setup", "status", "check"} // Names returns the schema names Get accepts: hub, targets, opt-in, -// operations, report, doctor and setup. +// operations, report, doctor, setup, status and check. func Names() []string { return append([]string(nil), names...) } // Get returns the JSON Schema called name: "hub" (hub.yml), "targets" // (targets.yml), "opt-in" (the target's opt-in file), "operations" (the // hub's .touchmark/operations.yml), "report" (the JSON report of plan and -// distribute, report/v1), "doctor" (the JSON report of doctor, doctor/v1) -// or "setup" (the JSON report of setup, setup/v1). +// distribute, report/v1), "doctor" (the JSON report of doctor, doctor/v1), +// "setup" (the JSON report of setup, setup/v1), "status" (the JSON report +// of status and apply, status/v1) or "check" (the JSON report of check, +// check/v1). func Get(name string) ([]byte, bool) { for _, n := range names { if n == name { diff --git a/schemas/hub.schema.json b/schemas/hub.schema.json index a673bed..0135dff 100644 --- a/schemas/hub.schema.json +++ b/schemas/hub.schema.json @@ -54,7 +54,7 @@ } }, "author": { - "description": "Removed in v1 and ignored with a warning: commits are always authored by the writer account.", + "description": "Ignored with a warning: commits are always authored by the writer account.", "deprecated": true, "type": ["object", "null"], "additionalProperties": false, diff --git a/schemas/status.schema.json b/schemas/status.schema.json new file mode 100644 index 0000000..3d856ef --- /dev/null +++ b/schemas/status.schema.json @@ -0,0 +1,208 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/bedrock-python/touchmark/master/schemas/status.schema.json", + "title": "touchmark status and apply report", + "description": "The JSON report of touchmark status and touchmark apply (status/v1): one target's working tree against the hub, one entry per managed path, and for apply what it did with each change.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "command", "dry_run", "hub", "target", "selection", "entries", "summary", "warnings"], + "properties": { + "schema": { + "description": "Report format: status/v1.", + "const": "status/v1" + }, + "command": { "enum": ["status", "apply"] }, + "dry_run": { + "description": "apply --dry-run: the plan only, nothing written. Always false for status.", + "type": "boolean" + }, + "hub": { "$ref": "#/$defs/hub" }, + "target": { + "description": "The target working tree.", + "type": "object", + "additionalProperties": false, + "required": ["root", "ref", "opt_in_file", "opted_in", "opt_in"], + "properties": { + "root": { + "description": "The target's working tree.", + "type": "string" + }, + "ref": { + "description": "The target in targets.yml, path or provider:path; empty when it could not be determined.", + "type": "string" + }, + "opt_in_file": { + "description": "The opt-in file's path in the target.", + "type": "string" + }, + "opted_in": { + "description": "The target gets packs: opt_in is file, assumed or flag.", + "type": "boolean" + }, + "opt_in": { + "description": "Why the target is opted in or not. file: the opt-in file is there; assumed: there is none, and a repo: entry with opt_in: assumed subscribes it; flag: there is none, and --assume-opt-in counts it as opted in; opted-out: the file says enabled: false; none: neither.", + "enum": ["file", "assumed", "flag", "opted-out", "none"] + } + } + }, + "selection": { + "description": "The resolved pack list; null when the target has not opted in.", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["packs", "complete", "unresolved", "sources"], + "properties": { + "packs": { + "description": "The packs the target gets, requires included.", + "type": "array", + "items": { "type": "string" } + }, + "complete": { + "description": "Every pack named was found in the hub.", + "type": "boolean" + }, + "unresolved": { + "description": "Packs named that the hub does not have.", + "type": "array", + "items": { "type": "string" } + }, + "sources": { + "description": "Per pack, where it was selected: the opt-in file, targets.yml, defaults or requires .", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "entries": { + "description": "The whole plan in plan order, one entry per managed path, current entries included.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "state", "action", "pack", "from", "to", "mode", "detail", "after_deletes"], + "properties": { + "path": { + "description": "The path in the target.", + "type": "string", + "minLength": 1 + }, + "state": { + "description": "The file's state in the target.", + "$ref": "#/$defs/state" + }, + "action": { + "description": "What apply does with it.", + "$ref": "#/$defs/action" + }, + "pack": { + "description": "The pack that ships the path, or shipped it.", + "type": "string" + }, + "from": { + "description": "The blob id the target has; empty when it has none.", + "$ref": "#/$defs/blob" + }, + "to": { + "description": "The blob id apply writes; empty for keep and delete.", + "$ref": "#/$defs/blob" + }, + "mode": { + "description": "The file mode apply writes; empty when it writes nothing.", + "enum": ["", "100644", "100755"] + }, + "detail": { + "description": "Why, for states and actions that need a reason.", + "type": "string" + }, + "after_deletes": { + "description": "Written after the deletes, because a path it needs is deleted first.", + "type": "boolean" + } + } + } + }, + "results": { + "description": "apply without --dry-run only: what it did with each entry whose action is not keep, in execution order.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "action", "state", "outcome", "detail"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "action": { "$ref": "#/$defs/action" }, + "state": { "$ref": "#/$defs/state" }, + "outcome": { "enum": ["done", "skipped", "failed"] }, + "detail": { + "description": "The reason for skipped and the error for failed.", + "type": "string" + } + } + } + }, + "summary": { + "description": "Entries per state, the changes (entries whose action is not keep) and, for apply, the outcomes.", + "type": "object", + "additionalProperties": false, + "required": ["missing", "current", "outdated", "local", "ignored", "retired", "retired_local", "unsafe", "orphaned", "changes", "done", "skipped", "failed"], + "properties": { + "missing": { "$ref": "#/$defs/count" }, + "current": { "$ref": "#/$defs/count" }, + "outdated": { "$ref": "#/$defs/count" }, + "local": { "$ref": "#/$defs/count" }, + "ignored": { "$ref": "#/$defs/count" }, + "retired": { "$ref": "#/$defs/count" }, + "retired_local": { "$ref": "#/$defs/count" }, + "unsafe": { "$ref": "#/$defs/count" }, + "orphaned": { "$ref": "#/$defs/count" }, + "changes": { "$ref": "#/$defs/count" }, + "done": { "$ref": "#/$defs/count" }, + "skipped": { "$ref": "#/$defs/count" }, + "failed": { "$ref": "#/$defs/count" } + } + }, + "warnings": { + "type": "array", + "items": { "type": "string" } + } + }, + "$defs": { + "hub": { + "description": "The hub the report was made from.", + "type": "object", + "additionalProperties": false, + "required": ["dir", "id", "commit"], + "properties": { + "dir": { + "description": "The hub's directory.", + "type": "string" + }, + "id": { + "description": "hub.yml's id; empty for a hub without hub.yml.", + "type": "string" + }, + "commit": { + "description": "The hub commit read; empty when touchmark stopped before reading it.", + "type": "string", + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})?$" + } + } + }, + "state": { + "enum": ["missing", "current", "outdated", "local", "ignored", "retired", "retired-local", "unsafe", "orphaned"] + }, + "action": { + "enum": ["keep", "create", "update", "delete", "adopt", "chmod"] + }, + "blob": { + "type": "string", + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})?$" + }, + "count": { + "type": "integer", + "minimum": 0 + } + } +} diff --git a/zensical.toml b/zensical.toml index 5e1e3e2..fd54d9a 100644 --- a/zensical.toml +++ b/zensical.toml @@ -51,6 +51,7 @@ nav = [ { "Environment variables" = "reference/environment.md" }, { "Exit codes" = "reference/exit-codes.md" }, { "Reports" = "reference/output.md" }, + { "Compatibility" = "reference/compatibility.md" }, { "The GitHub Action" = "reference/action.md" }, { "Glossary" = "reference/glossary.md" }, ] },