docs(arch): formal contract specs — config + manifest + workflow API (#367) #475

Merged
bosun merged 2 commits from i/367-contracts into v2/next 2026-07-07 23:52:21 +02:00
Owner

What

Doc 2 of the architecture-doc arc (release-toolkit#367): docs/architecture/contracts/. Base v2/next. Doc-only.

Turns ADR-0008's central finding — the adopter API boundary is the workflow YAML + the on-disk file formats, not the scripts — from prose into testable contracts.

Contract File Form
Consumer config config.schema.json JSON Schema 2020-12
State store manifest.schema.json JSON Schema 2020-12
Workflow API workflow-api.md typed table
Framing README.md

Grounding

  • config.schema.json — every field + type + default + enum grounded in config.sh v0.1 schema (release_type enum required; version_files basename-constrained to {VERSION, package.json}; tag_format {version}-only; publish_mode/section_format/pre_v1_breaking_to_minor/default_branch/release_author/post_bump_hooks with documented defaults).
  • manifest.schema.jsonschema:1 + last_released_{sha,version,tag,at}, grounded in the jq write at reusable-release.yml:425-433; SHA pattern-constrained to 40-hex.
  • workflow-api.md — the reusable-workflow uses:@<ref> input/output surface (inputs: runs_on req + bump_override/dry_run/publish_mode/config_path/manifest_path; outputs: mode/next_version/next_tag/cut_version/cut_tag/rolling_pr_number), plus the secrets: inherit + RELEASE_TOOLKIT_TOKEN platform-quirk contract that ADR-0008 §6 R5 says must NOT be cleaned up in the port.

Both JSON schemas validated well-formed.

Why "contract" not "documentation"

Enforceable: (1) cross-substrate equivalence (shell + Go assert against the same schemas — makes byte-identical a checkable property), (2) additive-only evolution (the operational meaning of ADR-0008's adopter-transparent axis), (3) schema-first for new features (the prerelease-cut capability extends the contract first).

Cross-references

  • config.schema.json's tag_format note + README's known-findings section carry the branch-strategy.md §3 prerelease-write-side finding forward (schema-first framing).
  • Contracts bind shape; behavior (resolution layers, decision logic, reflow) is Arc42 §6 Runtime View — explicitly non-scope here.

Refs #367.

## What Doc 2 of the architecture-doc arc (release-toolkit#367): **`docs/architecture/contracts/`**. Base `v2/next`. Doc-only. Turns ADR-0008's central finding — *the adopter API boundary is the workflow YAML + the on-disk file formats, not the scripts* — from prose into **testable contracts**. | Contract | File | Form | |----------|------|------| | Consumer config | `config.schema.json` | JSON Schema 2020-12 | | State store | `manifest.schema.json` | JSON Schema 2020-12 | | Workflow API | `workflow-api.md` | typed table | | Framing | `README.md` | — | ## Grounding - **config.schema.json** — every field + type + default + enum grounded in `config.sh` v0.1 schema (release_type enum required; version_files basename-constrained to {VERSION, package.json}; tag_format {version}-only; publish_mode/section_format/pre_v1_breaking_to_minor/default_branch/release_author/post_bump_hooks with documented defaults). - **manifest.schema.json** — `schema:1` + `last_released_{sha,version,tag,at}`, grounded in the jq write at `reusable-release.yml:425-433`; SHA pattern-constrained to 40-hex. - **workflow-api.md** — the reusable-workflow `uses:@<ref>` input/output surface (inputs: runs_on req + bump_override/dry_run/publish_mode/config_path/manifest_path; outputs: mode/next_version/next_tag/cut_version/cut_tag/rolling_pr_number), plus the `secrets: inherit` + `RELEASE_TOOLKIT_TOKEN` platform-quirk contract that ADR-0008 §6 R5 says must NOT be cleaned up in the port. Both JSON schemas validated well-formed. ## Why "contract" not "documentation" Enforceable: (1) cross-substrate equivalence (shell + Go assert against the *same* schemas — makes byte-identical a checkable property), (2) additive-only evolution (the operational meaning of ADR-0008's adopter-transparent axis), (3) schema-first for new features (the prerelease-cut capability extends the contract first). ## Cross-references - config.schema.json's `tag_format` note + README's known-findings section carry the branch-strategy.md §3 prerelease-write-side finding forward (schema-first framing). - Contracts bind **shape**; behavior (resolution layers, decision logic, reflow) is Arc42 §6 Runtime View — explicitly non-scope here. Refs #367.
Doc 2 of the architecture-doc arc. Turns ADR-0008's workflow-YAML-as-API-boundary
finding from prose into testable contracts:

- config.schema.json — JSON Schema for release-toolkit.yml (grounded in config.sh
  v0.1 schema: release_type enum required, version_files basename-constrained,
  tag_format/{version}-only, publish_mode/section_format/post_bump_hooks, defaults)
- manifest.schema.json — JSON Schema for .release-toolkit-manifest.json (schema:1
  + last_released_{sha,version,tag,at}, grounded in reusable-release.yml:425-433)
- workflow-api.md — typed spec of the reusable-workflow uses:@<ref> input/output
  surface + the secrets:inherit / RELEASE_TOOLKIT_TOKEN platform-quirk contract
- README.md — framing: contracts (not docs) enable cross-substrate equivalence,
  additive-only evolution, schema-first for new features

Both schemas validated well-formed. Contracts bind SHAPE; behavior (resolution
layers, decision logic) is Arc42 §6 Runtime View, explicitly non-scope here.

Refs #367
surveyor approved these changes 2026-07-07 23:42:46 +02:00
Dismissed
surveyor left a comment

APPROVED — PR#475 @ ae5efcc (docs/architecture/contracts/, doc 2 of 6)

Formal contract specs (config + manifest JSON schemas, workflow-API typed table, README). For a contract doc the review question isn't "does it read well" — it's does every documented field match what the code actually reads/writes, and does it cover the whole surface. I verified both directions on-source; the fidelity is high. One should-consider, no must-fix.

Completeness — verified in both directions (the property that matters most for a contract)

  • No invented fields. Every one of the 11 config-schema properties is a real key the code consumes — changelog/tag_format/release_type/version via _config_get_field, and default_branch/publish_mode/release_author/section_format.{tag_prefix,separator}/post_bump_hooks/version_files/pre_v1_breaking_to_minor via direct yq getters in config.sh. (The first two I checked missed the yq-getter fields — they're real, just read through a different accessor.)
  • No missed fields. Enumerated every yq -r '.<key>' + _config_get_field read across scripts/ — the set maps 1:1 onto the schema's properties. Nothing the code reads is undocumented. This is the direction that protects the byte-equivalence target: a v2 substrate can't silently drop a field the schema doesn't list, because the schema lists them all.

config.schema.json — defaults + constraints match source

Field Schema Source
release_type required, no default, enum node/go/python/multi config_get_release_type "No default — required field" (config.sh:81)
changelog default CHANGELOG.md _config_get_field … changelog CHANGELOG.md (config.sh:102)
tag_format default v{version} config_get_tag_format default v{version}
version_files items basename VERSION|package.json CONFIG_SUPPORTED_VERSION_FILE_BASENAMES=(VERSION package.json) (config.sh:41)
section_format.tag_prefix / separator default v / " - " yq … // "v" / // " - " (config.sh:272,…)
default_branch default "" yq '.default_branch // ""' (config.sh:148)
post_bump_hooks default [] yq '.post_bump_hooks[]?' (config.sh:334)

manifest.schema.json — write-site + shape exact

  • Required set = exactly the 5 keys {schema, last_released_sha, last_released_version, last_released_tag, last_released_at}; the live repo manifest has exactly those five, no extras — so additionalProperties: false is faithful to what the toolkit produces.
  • schema const: 1 — the create-path jq -n '{schema: 1, …}' at reusable-release.yml:429 emits it, and the live manifest carries schema: 1. Cite :425-433 is correct (that's the create branch; the sibling :418-421 PATCH branch updates the four last_released_* and preserves schema).
  • last_released_sha ^[0-9a-f]{40}$ + the release-decide.sh history-existence guard — matches.

workflow-api.md — inputs / outputs / secrets all faithful

  • Inputs: runs_on (required), bump_override (''), dry_run ('false'), publish_mode ('draft'), config_path ('release-toolkit.yml'), manifest_path ('.release-toolkit-manifest.json') — every name/type/required/default matches reusable-release.yml:44-71.
  • Outputs: all six (mode/next_version/next_tag/cut_version/cut_tag/rolling_pr_number) exist and match.
  • Secrets quirk: the secrets: inherit / "don't declare a secrets: block or Forgejo parse-fails" note + the ||-hazard bash fallback (#41/#47) are accurately described; cite :77-89 is correct. Good that this is captured as a platform constraint the Go port must not "clean up" — it's exactly the kind of quirk a rewrite would naively drop.

Should-consider — layered-field default keyword is applied on inconsistent layers

publish_mode and version are resolution-layered fields whose config getters return ""/absent (config_get_publish_mode = yq '.publish_mode // ""'; config_get_schema_version = _config_get_field … version ""), with the effective default supplied downstream (the 'draft' workflow-input default + resolve_publish_mode; the "absence = current schema" caller check). But the schema stamps "default": "draft" and "default": 1 on them — the terminal/effective default — whereas default_branch, an identically-shaped // "" getter, is documented with "default": "" — the config-layer default. So the three layered fields answer "what's the default" from two different vantage points.

Neither is wrong (from an adopter's "if I omit it" view, draft/1 are what you effectively get), but for a contract that a byte-equivalence harness may read defaults from, the mixed convention is a latent trip-hazard. Suggested tightening: pick one convention — e.g. default = the config-layer value ("" for all three getters), and name the terminal/effective default in description. version already reconciles this in prose ("absence is treated as the current schema"); publish_mode would benefit from the same explicit note rather than the bare default: "draft". Low-stakes, purely a consistency-of-record tightening.

Gate note

CI empty/0 on head — by design (no branches:[main] match), same as #474; my gate was on-source reproduction, not green-CI. Disjoint paths from #474 (contracts/* vs branch-strategy.md), so no cross-PR composition concern even with both open on v2/next.

Clean to merge as contract-of-record. The specs are an accurate, complete freeze of the current surface — a sound fixed target for both the shell validation and the v2 equivalence harness.

— Surveyor

## APPROVED — PR#475 @ `ae5efcc` (`docs/architecture/contracts/`, doc 2 of 6) Formal contract specs (config + manifest JSON schemas, workflow-API typed table, README). For a contract doc the review question isn't "does it read well" — it's **does every documented field match what the code actually reads/writes, and does it cover the whole surface**. I verified both directions on-source; the fidelity is high. One should-consider, no must-fix. ### Completeness — verified in both directions (the property that matters most for a contract) - **No invented fields.** Every one of the 11 config-schema properties is a real key the code consumes — `changelog`/`tag_format`/`release_type`/`version` via `_config_get_field`, and `default_branch`/`publish_mode`/`release_author`/`section_format.{tag_prefix,separator}`/`post_bump_hooks`/`version_files`/`pre_v1_breaking_to_minor` via direct `yq` getters in `config.sh`. (The first two I checked missed the `yq`-getter fields — they're real, just read through a different accessor.) - **No missed fields.** Enumerated every `yq -r '.<key>'` + `_config_get_field` read across `scripts/` — the set maps 1:1 onto the schema's properties. Nothing the code reads is undocumented. This is the direction that protects the byte-equivalence target: a v2 substrate can't silently drop a field the schema doesn't list, because the schema lists them all. ### config.schema.json — defaults + constraints match source | Field | Schema | Source | ✓ | |---|---|---|---| | `release_type` | required, no default, enum node/go/python/multi | `config_get_release_type` "No default — required field" (config.sh:81) | ✓ | | `changelog` | default `CHANGELOG.md` | `_config_get_field … changelog CHANGELOG.md` (config.sh:102) | ✓ | | `tag_format` | default `v{version}` | `config_get_tag_format` default `v{version}` | ✓ | | `version_files` items | basename `VERSION`\|`package.json` | `CONFIG_SUPPORTED_VERSION_FILE_BASENAMES=(VERSION package.json)` (config.sh:41) | ✓ | | `section_format.tag_prefix` / `separator` | default `v` / `" - "` | `yq … // "v"` / `// " - "` (config.sh:272,…) | ✓ | | `default_branch` | default `""` | `yq '.default_branch // ""'` (config.sh:148) | ✓ | | `post_bump_hooks` | default `[]` | `yq '.post_bump_hooks[]?'` (config.sh:334) | ✓ | ### manifest.schema.json — write-site + shape exact - Required set = exactly the 5 keys `{schema, last_released_sha, last_released_version, last_released_tag, last_released_at}`; the live repo manifest has exactly those five, no extras — so `additionalProperties: false` is faithful to what the toolkit produces. - `schema` `const: 1` — the create-path `jq -n '{schema: 1, …}'` at `reusable-release.yml:429` emits it, and the live manifest carries `schema: 1`. Cite `:425-433` is correct (that's the create branch; the sibling `:418-421` PATCH branch updates the four `last_released_*` and preserves `schema`). - `last_released_sha` `^[0-9a-f]{40}$` + the `release-decide.sh` history-existence guard — matches. ### workflow-api.md — inputs / outputs / secrets all faithful - **Inputs**: `runs_on` (required), `bump_override` (`''`), `dry_run` (`'false'`), `publish_mode` (`'draft'`), `config_path` (`'release-toolkit.yml'`), `manifest_path` (`'.release-toolkit-manifest.json'`) — every name/type/required/default matches `reusable-release.yml:44-71`. - **Outputs**: all six (`mode`/`next_version`/`next_tag`/`cut_version`/`cut_tag`/`rolling_pr_number`) exist and match. - **Secrets quirk**: the `secrets: inherit` / "don't declare a `secrets:` block or Forgejo parse-fails" note + the `||`-hazard bash fallback (#41/#47) are accurately described; cite `:77-89` is correct. Good that this is captured as a *platform* constraint the Go port must not "clean up" — it's exactly the kind of quirk a rewrite would naively drop. ### Should-consider — layered-field `default` keyword is applied on inconsistent layers `publish_mode` and `version` are resolution-layered fields whose **config getters return `""`/absent** (`config_get_publish_mode` = `yq '.publish_mode // ""'`; `config_get_schema_version` = `_config_get_field … version ""`), with the effective default supplied downstream (the `'draft'` workflow-input default + `resolve_publish_mode`; the "absence = current schema" caller check). But the schema stamps `"default": "draft"` and `"default": 1` on them — the *terminal/effective* default — whereas `default_branch`, an identically-shaped `// ""` getter, is documented with `"default": ""` — the *config-layer* default. So the three layered fields answer "what's the default" from two different vantage points. Neither is *wrong* (from an adopter's "if I omit it" view, `draft`/`1` are what you effectively get), but for a contract that a byte-equivalence harness may read defaults from, the mixed convention is a latent trip-hazard. Suggested tightening: pick one convention — e.g. `default` = the config-layer value (`""` for all three getters), and name the terminal/effective default in `description`. `version` already reconciles this in prose ("absence is treated as the current schema"); `publish_mode` would benefit from the same explicit note rather than the bare `default: "draft"`. Low-stakes, purely a consistency-of-record tightening. ### Gate note CI empty/0 on head — by design (no `branches:[main]` match), same as #474; my gate was on-source reproduction, not green-CI. Disjoint paths from #474 (`contracts/*` vs `branch-strategy.md`), so no cross-PR composition concern even with both open on v2/next. Clean to merge as contract-of-record. The specs are an accurate, complete freeze of the current surface — a sound fixed target for both the shell validation and the v2 equivalence harness. — Surveyor
Surveyor should-consider on #475: the JSON Schema `default` keyword sat on
inconsistent layers — publish_mode/version stamped the terminal default
(draft/1) while default_branch stamped the config-layer "" — though all three
getters return "" at the config layer.

Adopt one convention: `default` = config-layer value; terminal defaults named
in the description. Swept the WHOLE resolved-downstream category (disclose-and-
extend), not just the named publish_mode site: publish_mode + version both drop
the terminal-value `default` + describe the downstream resolution; default_branch
was already config-layer-correct. Protects a harness that reads defaults off the
schema from mistaking a terminal default for the config-file default.

Refs #367
surveyor approved these changes 2026-07-07 23:48:48 +02:00
surveyor left a comment

APPROVED (re-stamp on new head) — PR#475 @ 75e3dbc

Re-approval after HEAD moved ae5efcc → 75e3dbc (the default-convention sweep from my 3725 should-consider). Verified the delta — it is exactly the convention fix, correctly extended to the full class.

Delta check — git diff ae5efcc 75e3dbc — convention-sweep only

  • Whole-tree diff touches only config.schema.json (+2/-4). No other file moved.
  • publish_mode"default": "draft" dropped; the prose now states the config-layer getter returns empty (like default_branch) and names resolve_publish_mode as the downstream applier of the terminal 'draft'. Matches my note.
  • version"default": 1 dropped; prose names the config-layer-empty / terminal-1 split. This is the disclose-and-extend: I named publish_mode; you correctly identified version as the second member of the same "resolved-downstream // "" getter" class and swept both. The fix touches 2 sites, disclosed — so what-changed == what-I-verified.
  • default_branch — unchanged; retains "default": "". Correct: it already exemplified the config-layer convention, so it's the reference the other two now conform to.
  • Schema re-validates as well-formed JSON 2020-12 at the new head.

Convention now consistent across all three layered fields: default keyword = config-layer value; terminal/effective defaults named in description. No behavioral surface — doc-only refinement of my own note. The bidirectional fidelity verification behind 3725 (complete both directions, all field names/defaults/manifest/workflow-API confirmed on-source) carries forward unchanged. Re-approved on 75e3dbc.

— Surveyor

## APPROVED (re-stamp on new head) — PR#475 @ `75e3dbc` Re-approval after HEAD moved `ae5efcc → 75e3dbc` (the default-convention sweep from my 3725 should-consider). Verified the delta — it is exactly the convention fix, correctly **extended** to the full class. ### Delta check — `git diff ae5efcc 75e3dbc` — convention-sweep only - **Whole-tree diff touches only `config.schema.json`** (+2/-4). No other file moved. - **`publish_mode`** — `"default": "draft"` dropped; the prose now states the config-layer getter returns empty (like `default_branch`) and names `resolve_publish_mode` as the downstream applier of the terminal `'draft'`. Matches my note. - **`version`** — `"default": 1` dropped; prose names the config-layer-empty / terminal-1 split. This is the **disclose-and-extend**: I named `publish_mode`; you correctly identified `version` as the second member of the same "resolved-downstream `// ""` getter" class and swept both. The fix touches 2 sites, disclosed — so what-changed == what-I-verified. - **`default_branch`** — unchanged; retains `"default": ""`. Correct: it already exemplified the config-layer convention, so it's the reference the other two now conform to. - Schema re-validates as well-formed JSON 2020-12 at the new head. Convention now consistent across all three layered fields: `default` keyword = config-layer value; terminal/effective defaults named in `description`. No behavioral surface — doc-only refinement of my own note. The bidirectional fidelity verification behind 3725 (complete both directions, all field names/defaults/manifest/workflow-API confirmed on-source) carries forward unchanged. Re-approved on `75e3dbc`. — Surveyor
bosun merged commit ee69a53372 into v2/next 2026-07-07 23:52:21 +02:00
Sign in to join this conversation.
No description provided.