diff --git a/AUDIT.md b/AUDIT.md index 96afc418..22aadfbc 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -28,6 +28,7 @@ Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If th - `pyproject.toml` / `setup.py` -> `python`, a `pypa/gh-action-pypi-publish` workflow -> `pypi`. - `Dockerfile` + a docker build/push workflow -> `docker`, an `upstream-version.json` tracker -> `upstream-wrapper`. - `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`, a codegen workflow -> `codegen`, no `build-*` task -> `source-only`, governance-only -> `docs`. +- `hugo.yaml` / `hugo.toml` / `config/_default/hugo.yaml` -> `hugo`. A repo may carry it alongside `source-only`, since a site deploy leaf is not a `build-*` task and both declarations stay true. ## 3. Applicability Gate @@ -50,6 +51,7 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the - **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter), and a standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. - **console** - smoke runtime matrix is a strict subset, and per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. - **docker** - registry layer cache (`buildcache-`, never `type=gha`), the size-limited Docker Hub README is published via the docker-readme task, and the image always re-pushes on publish. +- **hugo** - the build fails on a generator warning, the URL-parity gate asserts a length floor before comparing, the rendered output is untracked, the generator is pinned by version and checksum and declared once, a vendored tree records its upstream ref, and the deploy asserts what the host serves (the release id and the environment). Retention is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is confined write-only, so grade which shape the repo uses rather than looking for a prune step. Deploy credentials are per-environment, which `spec/secrets.json` cannot express, so a clean **repo-setup** verdict says nothing about whether the environments are configured. - **branch-model** - `main` and `develop` both exist and are protected, and the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). - **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). - **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 32738f45..a4282ced 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -82,6 +82,7 @@ The **two-phase model is the default**: PRs build fast, publishing is batched. S - *Files attached to the GitHub Release* (zips, binaries, packaged libraries): one leaf task per output, each uploading `release-asset--`. A data-only repo (e.g. a symbol library) has exactly one such task: validate -> `zip` -> upload `release-asset--library`. It deletes the nuget/pypi/executable/docker jobs and the `publish-pypi` job, keeps `github-release` as-is. This is also where the .NET `build-executable-task` lives, and it is *not* a generic file step but specifically `dotnet publish` of the console app; replace it wholesale, don't adapt it. - *Package-registry pushes* (NuGet.org, PyPI): the leaf task both builds **and** publishes to its registry. NuGet pushes from inside `build-nugetlibrary-task` (`dotnet nuget push --skip-duplicate`) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: `build-pypilibrary-task` only builds + uploads the `pypilibrary-build-` artifact, and the separate `publish-pypi` job in `publish-release.yml` does the OIDC Trusted-Publishing upload (so `id-token: write` is granted only at that one entry point), and PyPI contributes **no** `release-asset-*`. - *Image-registry pushes* (Docker Hub): `build-docker-task` pushes multi-arch tags directly and contributes **no** `release-asset-*`. The image tag is build-layer-owned, so drive it from whatever version source fits (NBGV `SemVer2`, an upstream-release pin, or a per-image matrix). To publish the Docker Hub repository overview, `publish-docker-readme-task.yml` pushes `Docker/README.md` via `peter-evans/dockerhub-description` (single-repo by default, matrix per image for multi-image repos), wired into `publish-release.yml` and gated to `main`. + - *Filesystem on a host the project owns* (a static site, a config tree): a deploy leaf builds the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than the repository secret store. Its last step asserts what the host actually serves, the release id and the environment, never that the transport exited zero. Retention at the destination is bounded by a declared count, and one side is recorded as owning the prune: the deploy where its credential can observe the destination, the host where that credential is deliberately write-only. - *Source-only / no build* (validate + tag + release): this seam does not apply. A source-only repo carries **no** `build-release-task.yml` (its `appliesTo` excludes it), so there are no leaf tasks and no `get-version`/`github-release`/`date-badge` jobs to curate. Its whole release is the standalone [`publish-release.yml`](./.github/workflows/publish-release.yml) on `workflow_dispatch`: a `validate` job (the repo's reusable validation task) gates a publish job that **inlines** NBGV for the tag and `action-gh-release` for the release (tag + auto source archive + README + LICENSE). - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. - **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no `pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the shipped binary is byte-identical. This is accepted NBGV behavior; `pathFilters` are intentionally not added. diff --git a/STANDUP.md b/STANDUP.md index 70958f6f..0b5a2d75 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -129,8 +129,8 @@ Run [`AUDIT.md`][audit] end to end. The repo is stood up only when it is **opera When a repo matches no existing type, the work is onboarding a **type**, not just a repo: -1. Add the type to [`spec/project-types.json`][project-types] (`detect[]`, plus `checks` with verdict tiers and intent refs) and any per-type files to [`spec/files.json`][files], then add its publish mechanism to [`spec/secrets.json`][secrets] if new. -2. Add the reference workflow leaf to [`catalog/snippets/workflows/`][workflows] and document the type's [`WORKFLOW.md`][workflow] walkthrough. +1. Add the type to [`spec/project-types.json`][project-types] (`detect[]`, plus `checks` with verdict tiers and intent refs) and any per-type files to [`spec/files.json`][files], then add its publish mechanism to [`spec/secrets.json`][secrets] if new. Add the type's token to [`spec/scope-model.md`][scope-model] and the type itself to [`spec/type-model.md`][type-model] in the same change, which that file's own rule requires. A type publishing to a **new destination** also needs the target added to the closed `target` enum in [`registry/repos.schema.json`][repos-schema] and mapped in `targetMechanisms`, or the first repo declaring it fails `spec/validate.py` with an unknown-target error. +2. Add the reference workflow leaf to [`catalog/snippets/workflows/`][workflows] and document the type's [`WORKFLOW.md`][workflow] walkthrough. A leaf must not be named `build-*-task.yml` unless the type really is a build target, since `source-only.detect` is literally "no `build-*-task.yml`" and the name alone would make that declaration false for any repo carrying both. 3. Add the type to the [conformance matrix][matrix] and run the cold-start self-test until a context-free agent stands it up to operational. ## Self-Test: Cold-Start Conformance @@ -165,9 +165,11 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [repo-config-carry]: ./docs/repo-config-carry.md [repo-config-readme]: ./repo-config/README.md [repos]: ./registry/repos.json +[repos-schema]: ./registry/repos.schema.json [scope-model]: ./spec/scope-model.md [secrets]: ./spec/secrets.json [section-model]: ./spec/section-model.md [spec]: ./spec/ +[type-model]: ./spec/type-model.md [validate]: ./spec/validate.py [workflow]: ./WORKFLOW.md diff --git a/TODO.md b/TODO.md index a99ead4a..a4ffcde7 100644 --- a/TODO.md +++ b/TODO.md @@ -45,7 +45,7 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a - Declare the canonical short description in [`registry/repos.json`][repos] instead of deriving it by parsing the README, which makes every check and every push deterministic. Today [`spec/audit.py`][audit] extracts the intro line after the H1 and compares the GitHub About panel and the Docker Hub short description against it, so the source of truth lives inside prose. That forces anything wanting the value to parse markdown, and PhotoCleaner#32 shows the cost, since a workflow step that reads the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. Adding a `description` field turns the README intro into a third mirror rather than the source, so the audit compares all three against one declared value, the Docker workflow pushes a literal with no parser, and `repo-config/configure.sh` can set the GitHub About panel from the same field it already sets every other repository setting from. The 100-character cap stays, since Docker Hub's short description is still the tightest surface. Add the field to `registry/repos.schema.json` as well, which sets `additionalProperties: false`, and make it optional at first so the audit falls back to the README intro while repos adopt it one at a time. The disposition is recorded on [#509][issue-509], and the ask on the Docker repos is only that the parsing step is not propagated further while the field is pending. - Rework [`spec/readme-structure.md`][readme-structure] to match the hand-crafted PlexCleaner README, which is the shape the maintainer wants, and make the result auditable rather than advisory. Four concrete divergences are already identified, measured against PlexCleaner `README.md`, this repo's `README.md`, and the current spec. First, the distribution bullet is labeled by deliverable: PlexCleaner ships executables and calls the channel **Binary Releases**, while the spec fixes the label as **Versioned Releases** for every repo, so the label belongs in a per-channel table rather than as one string. Second, the license shield sits in the top **Build Status** block here and at the very bottom of PlexCleaner, inside a closing `## License` section that reads `Licensed under the [MIT License]` followed by the shield, immediately before the link definitions. Third, the Release Notes section closes with `See Release History for complete release notes and older versions.` in PlexCleaner against `See Release History for the full history.` here, and the PlexCleaner form is the wanted one. Note that PlexCleaner writes that link inline, which the reference-style rule forbids, so adopt the wording and keep the reference form. Fourth, the channel bullets and their shields vary by deliverable, meaning GitHub binaries, Docker Hub, NuGet, and PyPI each carry a different bullet label and a different shield set, which is what a per-type table has to encode for the `readme-structure` audit dimension to check a repo against its own declared types. - Decide whether the canonical README section order follows PlexCleaner, which is a separate question from the four divergences above and affects every repo plus the `readme-structure` audit. PlexCleaner places **Questions or Issues** immediately after the Table of Contents, where the spec orders it ninth, and it carries sections the spec names nowhere, including Performance Considerations, Runtime Metrics, Custom Plugins, Testing, Development Tooling, Feature Ideas, and Sample Media Files. Under the recurrence rule in [`spec/section-model.md`][section-model] those last ones are correctly repo-specific and stay undeclared, so the open question is only the position of the sections the spec already names. -- Declare locally-required secrets the way GitHub-stored ones are already declared, and make a gitignored `secrets/` directory the fleet standard that holds them. [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores, so a repo that deploys somewhere has no declared way to say what it needs at runtime, and the required set is discoverable only by reading the deploy. The pattern already runs in the fleet in two shapes: HomeAutomation-Config keeps a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeps a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the same split the GitHub side already gets from `requiredSecrets[]`. Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. The hub carries neither the directory nor a `.gitignore` entry for one today, so adopting it here comes first. +- Declare locally-required secrets the way GitHub-stored ones are already declared, and make a gitignored `secrets/` directory the fleet standard that holds them. [`spec/secrets.json`][secrets] covers only the Actions and Dependabot stores, so a repo that deploys somewhere has no declared way to say what it needs at runtime, and the required set is discoverable only by reading the deploy. The pattern already runs in the fleet in two shapes: HomeAutomation-Config keeps a gitignored secrets directory of env files and Docker secret files, and ESPHome-Config keeps a gitignored `secrets.yaml` beside a committed `_secrets.yaml`. The committed file carries the required names with dummy values, so the shape of the requirement is in git while the values never are, which is the same split the GitHub side already gets from `requiredSecrets[]`. Blog needs it immediately, since it deploys on the proxmox host through HomeAutomation-Config's Docker Compose stack and carries the copy destinations and the internal URI. The hub carries neither the directory nor a `.gitignore` entry for one today, so adopting it here comes first. Note that the **GitHub** side has the same missing axis, surfaced by the `hugo` type: a deploy's credentials are per-environment GitHub Environment secrets and variables, `stores` is a closed enum of `actions` and `dependabot`, and `spec/audit.py` seeds `required_by_store` with those two keys and indexes it unguarded, so adding an `environments` value to the enum raises `KeyError` for every repo whose `publish[]` maps to that mechanism. The type landed with an optional `environments` block added to [`spec/secrets.schema.json`][secrets-schema] and described in `spec/secrets.json`'s note, so a repo that needs it may declare its per-environment names and the shape is legal rather than an invention. The hub's own `spec/secrets.json` carries no such block, having no environments of its own, and no tool reads one where it exists. That is honest but it is not a gate, so a clean audit says nothing about whether an environment is configured. Solving it properly means a store vocabulary that does not crash on an unknown key and a per-environment secrets and variables fetch, and it is the same axis as the local half above rather than a separate problem. - Canonicalize Python linter-config placement on `pyproject.toml`, since one cataloged repo uses a standalone `.ruff.toml` plus `pyrightconfig.json`. Track it as a drift finding and fix it downstream. - Populate [reports/][reports] for the cataloged repos that still have no audit, since a registry `status` of `cataloged` asserts a result that only a committed report evidences. Eight repos have one. This is paced by maintainer capacity rather than blocked on anything, since repos are brought up to spec as they are worked on, so the entry records the outstanding set rather than a defect. - Revisit automating the audit, which was explored and deliberately deferred, recorded here so the reasoning is not re-derived from scratch. Three shapes were considered: a scheduled hub-driven audit publishing each report as a workflow artifact, the same thing committing the report back the way the codegen bot updates its own files, and a pull-request hook in each downstream repo that audits itself against the current hub. Three things blocked all of them. Until the fleet reaches stasis with every repo onboarded, a scheduled run reports mostly noise, since a repo mid-onboarding is expected to be non-conformant. The hub has to be stable before downstreams can audit against it, because a hub change lands as fleet-wide findings the same day. And the downstream half is a catch-22, since a self-auditing pull-request hook is CI instrumentation the repos that most need it do not yet carry. The agreed outcome was the on-demand audit that [`AUDIT.md`][audit-doc] describes today. Worth reopening once the fleet is onboarded and the hub goes a stretch without carried-content changes, and the artifact shape is the one to try first, since it produces evidence without committing anything and so cannot generate review load while the noise level is still unknown. @@ -53,7 +53,7 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a ## Onboarding and New Types - Give [`STANDUP.md`][standup] an explicit branch-bootstrap step, because it currently says nothing about how `main` and `develop` come into being and an agent therefore commits onto whichever branch it finds. Note that section 0A now covers the human-gated prerequisites from [#490][issue-490], so this is the remaining half of that gap rather than the whole of it. The sequence that avoids every cleanup problem is: create `main` and leave it carrying nothing, create `develop` from `main` and leave it carrying nothing, create the first feature branch from `develop` and do the whole standup there, add the repo to GitHub and apply the repo config while still on that branch, then open a normal pull request to `develop` when the repo is ready. Nothing ever has to be cleaned off `main` or `develop`, because nothing ever reached them without review. Note the mechanical wrinkle when writing this up: a git branch cannot exist without a commit, so "carrying nothing" means exactly one signed empty root commit (`git commit --allow-empty`), and section 0's signing window applies to that commit like any other. The alternative of committing onto `develop` and squashing afterwards does not work: `non_fast_forward` is set on both `develop` payloads, so the history cannot be rewritten without disabling the ruleset, and Blog was correctly blocked when it tried. Worth stating that the protection is uneven, since a `release` repo's `repo-config/develop.json` carries a `pull_request` rule that blocks a direct commit outright, while `repo-config/operational/develop.json` carries only `deletion`, `non_fast_forward` and `required_signatures`, so on an operational repo a direct commit succeeds and only the instruction stands between the agent and an unfixable history. The reason it matters on a public repo is that the exploratory standup history is where PII, secrets and noise commits accumulate, and squashing a feature branch is the one chance to leave them out. -- Add the static-site generator type that [#456][issue-456] has been gathering answers for, once Blog reports its measured shape rather than its expected one. The intake is answered in detail across that issue: dispatch-only publish with the tag cut last after a live check, rsync over SSH into a per-release directory with an atomic symlink swap, one secret plus per-environment variables, `staging` and `production` environments, theme as a Dependabot-tracked submodule, built output never committed, LF line endings under the operational model, and imported content excluded from the prose gates by an explicit provenance table. Two decisions are already settled against the first draft, that the deploy **is** a publish because the pipeline versions it with NBGV and readers consume it, and that the type is named for the generator rather than the transport with the checks phrased so they do not name the generator. The URL parity gate is the check of record, asserting a floor on the golden list length before comparing, since a truncated list makes every check below it pass vacuously. Blog is interim-classified until the type exists, so the deferral clears when this lands. Note two caveats the issue also records and nothing else states: `source-only.detect` is literally `["no build-*-task.yml"]`, so a Hugo build leaf must not be named `build-site-task.yml` or the declaration becomes a false letter, and the `copilot_code_review` rule in both ruleset payloads does not gate a merge today because gated Copilot review is an invite-only beta, which deserves a sentence near the merge gate so no repo reads the rule as the enforcement and relaxes the manual discipline actually holding the line. +- The static-site generator type from [#456][issue-456] and [#558][issue-558] has landed as `hugo`, with the deploy destination as the `self-hosted` publish target and the `deploy-ssh` mechanism. Correct the record while reading this, because three details the intake predicted are wrong against what Blog actually runs, and planning from the prediction would encode requirements the repo does not meet: the theme is **vendored with no recorded upstream ref**, not a Dependabot-tracked submodule; the generator is **pinned by version and SHA256**, not run at latest; and the deploy is a **separate dispatch** from the GitHub release, not a tag cut last after the live check. What was settled and did hold: the deploy is a publish, the type is named for the generator with the generic checks phrased so they do not name it, and the URL parity gate asserting a floor on the golden list length before comparing is the check of record. What remains: promote the generator-agnostic `hugo.*` checks to a shared type when a second generator arrives, which `spec/type-model.md` "Generators" makes a registry edit by construction. Also still true and stated nowhere else, the `copilot_code_review` rule in both ruleset payloads does not gate a merge today because gated Copilot review is an invite-only beta, which deserves a sentence near the merge gate so no repo reads the rule as the enforcement and relaxes the manual discipline actually holding the line. - Finish onboarding hardening (from [#310][issue-310]): make the [`AUDIT.md`][audit-doc] audit a required onboarding step and run the per-type cold-start self-tests tracked in [reports/conformance-matrix.md][matrix]. `STANDUP.md` is already in place. - Add a linter-only Python project type for codegen and boilerplate Python, code that runs during another tool's build to emit generated source (for example ESPHome codegen that produces enriched C++ at compile time), so it ships no unit tests and no coverage and needs only the linter. Keep it distinct from the existing `python` type, which is utility code that can and should carry unit tests and coverage, as in PlexCleaner. Until it exists, ESPHome-Config stays `source-only` and its `+python` reclassification is deferred, so accept its one outstanding validation finding meanwhile. - Add a fleet-standard clang-format config for the `cpp` type, a catalog snippet plus a CODESTYLE C++ section defining the style, the C++ analogue of the shared ruff config, so the `cpp` clang-format check references one canonical style rather than each repo inventing its own. Base it on the ESPHome-Config agent's proposed `.clang-format`. @@ -91,6 +91,7 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a [issue-519]: https://github.com/ptr727/ProjectTemplate/issues/519 [issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521 [issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523 +[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 @@ -115,6 +116,7 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a [repos]: ./registry/repos.json [scripts]: ./scripts/README.md [secrets]: ./spec/secrets.json +[secrets-schema]: ./spec/secrets.schema.json [section-model]: ./spec/section-model.md [standup]: ./STANDUP.md [workflows]: ./catalog/snippets/workflows/ diff --git a/WORKFLOW.md b/WORKFLOW.md index df2ffff3..de19870e 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -132,6 +132,7 @@ Pick each output's path by **where the artifact goes**: - **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). - **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`. PyPI is **split**: the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. - **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). - **No file target via the release task** (Docker-only, PyPI-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by a publisher that ships file targets, which keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited. With the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. A **source-only** repo has no release task at all. Its standalone `publish-release.yml` inlines `action-gh-release`, so `expect_release_assets` does not apply (see Section 6). ## 4. Behavioral Contract: Expected Outcomes @@ -169,6 +170,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo that uses the release task (Docker-only, PyPI-only) reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download). With the default `true` and no assets the release-create step fails. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. - **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. Registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* - **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes. `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker build on smoke. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. +- **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration; a URL contract checked against the wrong environment; a dead config watcher read as a routing fault.* ### D5 - Resource Cleanup @@ -177,6 +179,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* - **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. - **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* ### D6 - Seam / Architecture Conformance @@ -232,6 +235,7 @@ Read the workflow files plus `version.json` and assert the structural fact behin - **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` zip carries the package(s). - **PyPI:** `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted after publish. The `pypi` environment has a deployment-branch rule. - **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme/date-badge jobs are gated main-only. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. +- **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root; the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) @@ -250,6 +254,8 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the | S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | | S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | | S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6, D7.1 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1, D2.3 | ### 5C. Live Probe (Where Warranted) @@ -277,6 +283,7 @@ Each type maps the *applicable* S-scenarios onto its targets. The differences ar - **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. - **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke`, mirroring the nugetlibrary leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5). `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. - **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release: tag + source zip + README + LICENSE, with no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped**, and validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda, all recorded N/A, not failed. +- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only; any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls `deploy-site-task.yml` with `secrets: inherit`. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task pins the generator by version and checksum, and re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), installs the verified generator, derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), builds the tree, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, and finally checks the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade; what the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. - **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job`, built from the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), with **no unit tests**; its triggers differ from the `release` model: `push` to `develop` (advisory feedback on direct commits) plus `pull_request` to `main` (the enforced promotion gate) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, via the standalone publisher's inlined `action-gh-release`). Applicable scenarios: S1 (validation) on the promotion PR, plus the source-only release set: S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule, neither of which an operational repo has) and every build/registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index c0340390..d42e1e5e 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -13,6 +13,8 @@ The reusable build/publish workflow tasks a code-shipping repo runs. They are ** | `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4; section 6 Docker walkthrough | | `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4; section 3 Release Model | | `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4; section 6 Docker walkthrough | +| `deploy-site.yml` | Dispatch entry point for a site deploy: environment choice, per-environment concurrency, ref gate, shared validation | D2.1, D2.3, D7.1 | +| `deploy-site-task.yml` | Build a site and ship it to a filesystem on a host the project owns, then verify against the running host | D4.6, D5.6, D7.2 (section 6 static-site walkthrough) | | `check-upstream-version-task.yml` | Upstream-version tracker for wrapper repos | D3.5, D8.3 | | `run-codegen-pull-request-task.yml` | Deterministic codegen executor (per-branch PR) | D8.2 | | `run-periodic-codegen-pull-request.yml` | Scheduled codegen trigger over both branches | D8.2 | diff --git a/catalog/snippets/workflows/deploy-site-task.yml b/catalog/snippets/workflows/deploy-site-task.yml new file mode 100644 index 00000000..ed42da8f --- /dev/null +++ b/catalog/snippets/workflows/deploy-site-task.yml @@ -0,0 +1,203 @@ +name: Deploy site task + +# Reusable deploy of a built site to a filesystem on a host the project owns. +# It is a separate dispatch from the release, so a redeploy of an unchanged commit mints no tag. +# That matters because redeploying is routine: a host rebuild, a rollback, or proving a branch on a non-production environment. +# +# The transport here is rsync over SSH, but the transport is not the guarantee. +# What the contract fixes is the dispatch gate, the per-environment GitHub Environment credentials, and that success is asserted against what the host serves rather than against the transport's exit status. +# See WORKFLOW.md D4.6 and D5.6. +# +# Two steps call repo-owned scripts instead of inlining their work. +# The bundle assembly builds the site, gates it on the repo's URL contract, precompresses, and stamps the release id into the server configuration it ships. +# The live verification asserts the golden-list floors, the environment, the release id, and then the URL contract. +# Both are long, both are repo-specific, and both have to be runnable by hand outside CI. + +on: + workflow_call: + inputs: + # Selects the GitHub Environment, and is also the path segment the release lands under on the host. + environment: + description: The GitHub Environment to deploy to. + required: true + type: string + outputs: + # The caller records what shipped; without this a rollback has to read the host to find out. + release-id: + value: ${{ jobs.deploy.outputs.release-id }} + site-url: + value: ${{ jobs.deploy.outputs.site-url }} + +# Pinned by version and by checksum, because the site is reproducible only if the generator is. +# A floating installer action defeats the point, and a minor bump changes rendered output. +# Update both values together, and keep them equal to the pin the validation task installs. +# +# The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so two transfers cannot drift apart. +# StrictHostKeyChecking=yes refuses an unknown or changed host key outright, where the default asks and a non-interactive runner then resolves that ambiguously. +# UserKnownHostsFile names the file the deploy key step writes, so the check reads the pinned value rather than whatever the runner image happens to carry. +# BatchMode=yes makes every prompt an immediate failure, so a credential problem surfaces as a failed step rather than as a job that hangs to its timeout. +# IdentitiesOnly=yes stops the agent offering other keys, so the deploy authenticates as the confined account or not at all. +env: + HUGO_VERSION: 0.164.0 + HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd + SSH_TRANSPORT: >- + ssh -i ~/.ssh/deploy + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile=~/.ssh/known_hosts + -o BatchMode=yes + +jobs: + + # A job of its own, because the environment binding on the deploy job resolves before any step runs. + # A workflow_call caller is not bound by the dispatch choice list a human sees, so an unknown name would otherwise bind nothing and run anyway. + assert-environment: + name: Assert environment name job + runs-on: ubuntu-latest + permissions: {} + + steps: + + - name: Assert environment is known step + env: + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -Eeuo pipefail + case "$ENVIRONMENT" in + production | staging) ;; + *) + echo "::error::environment must be production or staging, got '$ENVIRONMENT'." + exit 1 + ;; + esac + + # Every host-specific value comes from the environment, so this file names no host, path, or address. + deploy: + name: Deploy site job + runs-on: ubuntu-latest + needs: [assert-environment] + environment: ${{ inputs.environment }} + permissions: + contents: read + outputs: + release-id: ${{ steps.release.outputs.id }} + site-url: ${{ vars.SITE_BASE_URL }} + + steps: + + # Full history, because a shallow clone silently changes page metadata where the generator reads git info. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Install generator step + run: | + set -Eeuo pipefail + deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb" + curl -sSLf -o "$deb" "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}" + echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict + sudo dpkg --install "$deb" + hugo version + + # Derived once and used three times: the directory name, the stamp in the shipped server config, and the value the live check expects. + # Deriving it twice yields ids seconds apart, and the check then asserts a version nothing installed. + # + # The run id and attempt make it unique by construction rather than by argument. + # A bare second-resolution timestamp is unique here only because a multi-minute install precedes this step and the concurrency group serializes an environment, which are both incidental. + # Reordering this step earlier, an obvious refactor, would silently make two ids collide, and a colliding id is the one value the live check cannot tell apart. + # The timestamp stays the prefix, so the id reads chronologically and a name-sorted prune keeps working. + - name: Resolve release id step + id: release + run: | + set -Eeuo pipefail + echo "id=$(date -u +%Y%m%d-%H%M%S)-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + # Assembled to a scratch path, since the environment's deploy root is on the far host. + - name: Assemble release bundle step + env: + SITE_BASE_URL: ${{ vars.SITE_BASE_URL }} + run: | + set -Eeuo pipefail + bash deploy/make-release.sh "${RUNNER_TEMP}/bundle" "${{ steps.release.outputs.id }}" + + # The known-hosts value is a variable rather than a secret: it is integrity-critical but not confidential, and a variable keeps it diff-visible. + # StrictHostKeyChecking is never disabled. + - name: Install deploy key step + env: + DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} + DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }} + run: | + set -Eeuo pipefail + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/deploy + chmod 600 ~/.ssh/deploy + printf '%s\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + # The destination is anchored at the key's confinement root, so no host path appears here. + # The hard-link source points at current, which still resolves to the previous release until the flip, so CI carries no state. + # A missing target is a warning rather than an error, so a first deploy into a fresh environment needs no special case. + # The mkpath flag creates the releases parent, which the transport will not create on its own. + # No delete flag: at an environment root it silently removes the releases a rollback needs. + - name: Upload release step + env: + DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }} + DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }} + RELEASE_ID: ${{ steps.release.outputs.id }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -Eeuo pipefail + rsync -az --mkpath --no-g --chmod=D2755,F644 \ + --link-dest="/${ENVIRONMENT}/current/" \ + -e "$SSH_TRANSPORT" \ + "${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \ + "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/" + + # A separate step from the upload, so a failed transfer cannot half-publish a site. + # The pointer is relative, so one bundle works at any remote root. + # The transport replaces a symlink through a temporary and a rename, so it is never absent to a request in flight. + - name: Flip current step + env: + DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }} + DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -Eeuo pipefail + rsync -a --no-recursive \ + -e "$SSH_TRANSPORT" \ + "${RUNNER_TEMP}/bundle/current" \ + "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/" + + # Retention is bounded and owned, and this step is only one of the two shapes D5.6 accepts. + # It applies where the deploy credential can observe the destination, so the count can be asserted after pruning and the deploy fails when it does not converge. + # A prune whose pattern stops matching reports success while the disk fills, which is the same shape as a gate that stops gating. + # The release current resolves to is never a candidate, whatever the sort order says. + # This destination is durable, so no artifact retention backstop expires it. + # + # Delete this step where the credential is confined write-only, as a forced rsync command with no send capability is. + # Such a key can neither delete nor read back, and widening it to satisfy a check would trade a real confinement boundary for the check. + # There the prune belongs to a host-side timer, and D5.6 is met by recording that ownership on the host side of the repo's runbook rather than by a step here. + # What D5.6 rejects is neither side owning it, since each then assumes the other prunes. + - name: Prune old releases step + env: + DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }} + DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }} + ENVIRONMENT: ${{ inputs.environment }} + KEEP_RELEASES: '10' + run: | + set -Eeuo pipefail + bash deploy/prune-releases.sh "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}" "${ENVIRONMENT}" "${KEEP_RELEASES}" + + # The only step that observes the running site (WORKFLOW.md D4.6). + # An upload succeeds against a container serving nothing, and a flip succeeds against a server that never reloads its rules. + # The check asserts which environment answered, then which release its rules came from, then the URL contract. + # The release value is a response header the artifact stamps into its own served configuration, so it settles without parsing a payload and survives an auth gate in front of the site. + - name: Verify URL contract step + env: + EXPECT_SITE_ENV: ${{ inputs.environment }} + EXPECT_RELEASE: ${{ steps.release.outputs.id }} + run: | + set -Eeuo pipefail + bash checks/check-live-urls.sh "${{ vars.SITE_BASE_URL }}" diff --git a/catalog/snippets/workflows/deploy-site.yml b/catalog/snippets/workflows/deploy-site.yml new file mode 100644 index 00000000..010c0e56 --- /dev/null +++ b/catalog/snippets/workflows/deploy-site.yml @@ -0,0 +1,63 @@ +name: Deploy site action + +# Dispatch entry point for a site deploy, and deliberately not the release publisher. +# A deploy ships an already-validated tree to one environment; the GitHub release is a separate dispatch that cuts the tag. +# Splitting them means a redeploy of an unchanged commit mints no tag, which is what makes a rollback or a host rebuild cheap. + +on: + workflow_dispatch: + inputs: + # A choice rather than free text, so a typo cannot bind an environment that does not exist. + # The callee re-asserts the name anyway, since a workflow_call caller is not bound by this list. + environment: + description: Environment to deploy to. + required: true + type: choice + options: + - staging + - production + +# Keyed on the environment rather than the ref, so two environments deploy independently. +# Runs queue rather than cancel, because a cancelled deploy leaves a release uploaded and unflipped. +concurrency: + group: ${{ github.workflow }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + + # First, so a mis-dispatched production deploy fails before anything is installed or written. + # Production is constrained to the default branch; a non-production environment deploys from any ref, since proving a branch before it merges is what that environment is for. + assert-ref: + name: Assert deploy ref job + runs-on: ubuntu-latest + permissions: {} + + steps: + + # Compared against the full ref rather than ref_name, because tags and branches are separate namespaces that share a short name. + # A tag named for the default branch would satisfy a ref_name comparison while being an arbitrary commit, which is a bypass of the one gate protecting production. + - name: Assert production deploys from the default branch step + run: | + set -Eeuo pipefail + if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref }}" != "refs/heads/${{ github.event.repository.default_branch }}" ]; then + echo "::error::Deploy production from the default branch, got ${{ github.ref }}." + exit 1 + fi + + # The same reusable validation task the pull request gate runs, so a dispatch cannot deploy a ref that fails validation. + validate: + name: Validate job + needs: [assert-ref] + permissions: + contents: read + uses: ./.github/workflows/validate-task.yml + + deploy: + name: Deploy job + needs: [validate] + permissions: + contents: read + uses: ./.github/workflows/deploy-site-task.yml + with: + environment: ${{ inputs.environment }} + secrets: inherit diff --git a/registry/repos.json b/registry/repos.json index 62bf599c..4625f882 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -309,17 +309,17 @@ "name": "Blog", "url": "https://github.com/ptr727/Blog", "status": "cataloged", - "types": ["source-only"], + "types": ["hugo", "source-only"], "groundTruthBranch": "main", "workflowModel": "release", "lineEndings": "lf", "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], + "publish": [{ "target": "github-release", "mechanism": "none" }, { "target": "self-hosted", "mechanism": "static-secret" }], "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Hugo static site migrated off WordPress.com, stood up 2026-08-01; release model with a dispatch-only publisher that cuts the tag and a source archive.", "lineEndings lf on a release repo, where the rule grants the native-platform default to operational repos only: every consumer is Linux (the Hugo build, the Caddy container, and the rsync deploy).", "types source-only rather than the docs the hub proposed, because both docs predicates are false for a repo that builds a site and gates a URL contract. Interim until the static-site-generator type lands, which #456 is holding for Blog's measured deploy shape.", "content/ is an imported WordPress archive, so the prose, spelling, and style gates are scoped to exclude it.", "The VPS deploy does not exist yet, so the publish target is the GitHub release only. Revisit publish[] once deploy-site.yml is proven."] + "driftNotes": ["Hugo static site migrated off WordPress.com, stood up 2026-08-01; release model with a dispatch-only publisher that cuts the tag and a source archive.", "lineEndings lf on a release repo, where the rule grants the native-platform default to operational repos only: every consumer is Linux (the Hugo build, the Caddy container, and the rsync deploy).", "content/ is an imported WordPress archive, so the prose, spelling, and style gates are scoped to exclude it.", "The vendored theme under themes/ records no upstream repository, ref, or commit, is not a submodule, and no Dependabot ecosystem covers it, so it cannot be moved or diffed against upstream (hugo.vendored.provenance).", "The generator version and its checksum are pinned in both validate-task.yml and deploy-site-task.yml with nothing asserting the two copies agree, so a one-sided bump validates and deploys different generators (hugo.generator.pinned).", "Deploy credentials are per-environment GitHub Environment secrets and variables, which spec/secrets.json has no vocabulary for, so requiredSecrets stays empty and the names are declared in this repo's own secrets.json environments block; listing them here would make the audit demand them in the repository actions store."] } ] } diff --git a/registry/repos.schema.json b/registry/repos.schema.json index bd14c2ff..fac95ffd 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -27,7 +27,7 @@ "workflowModel": { "enum": ["release", "operational"] }, "lineEndings": { "enum": ["lf", "crlf"] }, "mechanism": { "enum": ["oidc", "static-secret", "none"] }, - "target": { "enum": ["nuget", "pypi", "docker", "github-release"] }, + "target": { "enum": ["nuget", "pypi", "docker", "github-release", "self-hosted"] }, "repo": { "type": "object", "required": ["name", "url", "status"], diff --git a/reports/blog/audit.md b/reports/blog/audit.md index 8a0b41ce..db18b15b 100644 --- a/reports/blog/audit.md +++ b/reports/blog/audit.md @@ -5,6 +5,7 @@ - **Verdict:** operational - **Date:** 2026-08-03 - **Run stamp:** `audit run 2026-08-03T22:34:03Z | hub 3a7cc64` +- **Partly superseded 2026-08-05, re-run owed.** This run graded the repo as `source-only` against a hub with no static-site type. Since then the deploy has run, the `hugo` type and the `self-hosted` target landed, and the registry declares `types: ["hugo", "source-only"]`, so the **Types** line above and every `hugo.*` dimension this report does not have are stale rather than wrong. What the run observed is left as it was observed, per the run-stamp discipline: the annotation under Proposed Registry / Spec Updates records what has since been applied, and the next full run replaces this file rather than editing it. First audit of this repo. It was stood up on 2026-08-01 and reached a proven release path the same day, but it was never added to [`registry/repos.json`][repos], so no hub tool had measured it until now and [`reports/divergences.md`][divergences] under-reported the fleet by exactly this repo. The registry entry lands with this report. Nothing here is a defect: every finding is the hub advancing after the carry, which is the propagation job [#536][pr-536] exists to make possible. @@ -57,7 +58,7 @@ None. ## Proposed Registry / Spec Updates - Add the `Blog` registry entry. Applied in this change: `source-only`, `release` workflow model, `dispatch-only` release trigger, `lineEndings: "lf"`, and `driftNotes` recording the two declared deviations plus the interim classification. -- Revisit `publish[]` once the VPS deploy exists. It declares the GitHub release only, because that is the only channel that currently ships. [#456][issue-456] holds the static-site type pending this repo's measured deploy shape. +- Revisit `publish[]` once the VPS deploy exists. It declares the GitHub release only, because that is the only channel that currently ships. [#456][issue-456] holds the static-site type pending this repo's measured deploy shape. **Applied 2026-08-05**: the deploy has run, [#456][issue-456] and [#558][issue-558] are answered, the `hugo` type and the `self-hosted` target exist, and the registry entry now declares `types: ["hugo", "source-only"]` with both publish targets. The interim classification driftNotes are removed and three type checks are recorded against the repo instead, two of which are already fixed downstream. ## Escalations @@ -75,6 +76,7 @@ Two spec questions, raised rather than resolved, per AUDIT.md section 9. [issue-456]: https://github.com/ptr727/ProjectTemplate/issues/456 +[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 [pr-536]: https://github.com/ptr727/ProjectTemplate/pull/536 [pr-540]: https://github.com/ptr727/ProjectTemplate/pull/540 [pr-543]: https://github.com/ptr727/ProjectTemplate/pull/543 diff --git a/reports/conformance-matrix.md b/reports/conformance-matrix.md index d7e1e89d..912d06b8 100644 --- a/reports/conformance-matrix.md +++ b/reports/conformance-matrix.md @@ -11,7 +11,7 @@ The primary shapes are stood up as whole repos; the **composable targets** (`nug | Shape | Reference repo | Cold-standup | Last audited | First gap / notes | |---|---|---|---|---| | `python` + `source-only` | Financial-Modeling | not-tested | - | Reference for the source-release (dispatch-only) profile; the downstream standup issue is open. | -| `source-only` + `release` | Blog | not-tested | 2026-08-03 | Hugo static site (#456), stood up 2026-08-01 and cataloged 2026-08-03. It cut release `1.0.11` from a dispatch-only publisher, so `releaseTrigger` is `dispatch-only` and `publish[]` carries the GitHub release, both recorded from what ran rather than from what was predicted. The VPS deploy has still not run, so its target has no declared type yet. | +| `hugo` + `source-only` + `release` | Blog | not-tested | 2026-08-03 | Hugo static site (#456, #558), stood up 2026-08-01 and cataloged 2026-08-03. Release and deploy are independent surfaces: a dispatch-only publisher cuts the tag, and a separate dispatch deploys to a `self-hosted` filesystem per environment. Reclassified off the interim `source-only`-alone declaration when the type landed. Two deviations found by the type's own checks are open against the repo (ptr727/Blog#28, ptr727/Blog#29): the vendored theme records no upstream ref, and the generator pin is duplicated across two workflows. It is also the case that shaped `hugo.deploy.retention`: its deploy credential is confined write-only, so the deploy can neither prune nor read the destination back, and the prune is a host-side timer its runbook records as host-owned. The first draft of that check demanded an in-pipeline assertion and would have pushed a correct design to widen a deliberately narrow credential. The audit report predates the deploy and is due a re-run. | | `csharp` + `console` | - | not-tested | - | | | `csharp` + `docker` | - | not-tested | - | | | `csharp` + `python` | PlexCleaner | not-tested | - | First mixed-language shape (#339). Python is a stdlib-only `uvx` **scripts** profile subtree (`RegressionTests/`): no `uv.lock`, `pyproject.toml` lint/type config only, mypy checker, `python.uvlock.pinned` + `python.coverage.codecov` N/A; `codecov.yml` stays required for the C# side. Both language rule-sets apply (CODESTYLE.md "Two profiles"). | @@ -29,6 +29,7 @@ The primary shapes are stood up as whole repos; the **composable targets** (`nug | `nuget` | a `csharp` library base | not-tested | OIDC Trusted Publishing; no stored key. | | `pypi` | a `python` library base | not-tested | OIDC; `environment: pypi`, `skip-existing: true`. | | `docker` | any base with a Dockerfile | not-tested | Registry layer cache; always re-push. | +| `self-hosted` | a `hugo` base | not-tested | rsync over SSH into a per-environment release directory, with an atomic pointer flip. Retention takes either D5.6 shape: the deploy asserts the count where its credential can observe the destination, and the host owns it where that credential is confined write-only, which is the case on the first member. Credentials are per-environment GitHub Environment secrets and variables rather than repository secrets, so `spec/secrets.json` declares the mechanism with an empty `requires` and the audit cannot see whether the environments are configured. | ## Updating a Row diff --git a/spec/files.json b/spec/files.json index ee4db8bb..3be522ab 100644 --- a/spec/files.json +++ b/spec/files.json @@ -30,6 +30,8 @@ { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-dependabot", "disable-auto-merge-on-maintainer-push"] }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "GOVERNANCE.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, + { "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "secrets: inherit"] } }, "reference": "catalog/snippets/workflows/deploy-site.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, + { "path": ".github/workflows/deploy-site-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-environment", "deploy"], "requireTokensInJob": { "deploy": ["environment:"] } }, "reference": "catalog/snippets/workflows/deploy-site-task.yml", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["hugo"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] }, { "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, diff --git a/spec/project-types.json b/spec/project-types.json index 62703949..72df57f7 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -107,6 +107,22 @@ { "id": "sourceonly.nbgv.retained", "verdict": "letter", "assert": "version.json is retained and NBGV is inlined in publish-release.yml to compute the tag.", "workflowRef": "WORKFLOW.md#d3---versioning-and-classification" } ] }, + "hugo": { + "detect": ["hugo.yaml", "hugo.toml", "config/_default/hugo.yaml"], + "intentRefs": ["WORKFLOW.md"], + "note": "Named for the generator rather than for the transport, because what a repo builds and where the result lands are separate axes. The destination is publish[] ({ target, mechanism }), so a repo changes transport without changing type. Every assert below is phrased without naming the generator except hugo.build.strict, where a generator-specific flag is the letter, so promoting the generic ones to a shared type when a second generator arrives is a registry edit. Deploy credentials are per-environment GitHub Environment secrets and variables, which spec/secrets.json cannot yet express, so a repo does not list them in its registry requiredSecrets: spec/audit.py resolves that list against the repository actions store and would report an environment-scoped name as missing.", + "checks": [ + { "id": "hugo.build.strict", "verdict": "letter", "assert": "The site build fails on a generator warning rather than rendering around it (hugo --gc --minify --panicOnWarning), and the pull request gate and the deploy run the same build command rather than two variants.", "workflowRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke" }, + { "id": "hugo.urls.parity", "verdict": "letter", "assert": "A URL contract gate compares the built tree against a committed list of the URLs that must render and the URLs that must redirect, and asserts a minimum length on each list before comparing it, since a truncated list makes every assertion below it pass vacuously. This is the type's check of record, standing in for the unit tests a site does not have.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "hugo.output.uncommitted", "verdict": "letter", "assert": "The rendered output is produced in CI only: its roots are gitignored and untracked, and they are excluded from the prose, spelling, and markdown gates along with any vendored third-party tree. A committed render is drift rather than a deliverable.", "intentRef": "GOVERNANCE.md#documentation-style-conventions" }, + { "id": "hugo.generator.pinned", "verdict": "letter", "assert": "The generator is pinned by exact version and by a checksum of the downloaded artifact, verified before install, never installed from a floating action or a latest tag, since the site is reproducible only if the generator is. The pin is declared once, and where two workflows need it something asserts the two copies agree.", "workflowRef": "WORKFLOW.md#d9---style--static-see-section-2" }, + { "id": "hugo.vendored.provenance", "verdict": "letter", "assert": "A vendored third-party tree records the upstream repository and the exact ref or commit it was taken from, or is carried by a mechanism that pins it, so a bot or a tracker can move it. An unpinned copy with no recorded origin cannot be updated, diffed against upstream, or audited for a security fix.", "intentRef": "GOVERNANCE.md#release-model" }, + { "id": "hugo.deploy.environment", "verdict": "letter", "assert": "The deploy job binds a GitHub Environment and takes every host-specific value and its credential from that environment, so the workflow file names no host, path, or address. A reusable callee re-asserts the environment name in a job of its own, because the environment binding resolves before any step runs and a workflow_call caller is not bound by the dispatch choice list a human sees.", "workflowRef": "WORKFLOW.md#d7---concurrency-permissions-safety" }, + { "id": "hugo.deploy.atomic", "verdict": "intent", "assert": "A release installs beside the retained ones under its own immutable id, and is published by moving a single pointer through a temporary and a rename, so no request observes a half-written site and the previous release stays on disk as a rollback target. The transport never deletes at the environment root.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "hugo.deploy.verified", "verdict": "letter", "assert": "The deploy's terminal step observes the running host rather than the transport's exit status. It asserts which release is answering, by comparing a version the artifact stamps into the configuration it ships against the id just installed, polling to a bounded timeout because the reload is asynchronous, and it asserts which environment answered. An unreachable host is reported distinctly from an HTTP status.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, + { "id": "hugo.deploy.retention", "verdict": "letter", "assert": "Retention at the destination is bounded by a declared count, and the side that owns the prune is written down rather than assumed. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only and cannot observe it, the prune belongs to the host and that ownership is recorded, since widening the credential to reach it would trade a real boundary for a check. The release the live pointer resolves to is never a prune candidate. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this.", "workflowRef": "WORKFLOW.md#d5---resource-cleanup" } + ] + }, "docs": { "detect": ["governance-only repo"], "checks": [ diff --git a/spec/scope-model.md b/spec/scope-model.md index a0eafe05..3747fcd7 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -15,7 +15,7 @@ A selector is one token from one of four **disjoint** namespaces. Because the na | Namespace | Tokens | Source of truth | | --- | --- | --- | -| project type | `csharp` `nuget` `pypi` `python` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `docs` | [`spec/project-types.json`][project-types] | +| project type | `csharp` `nuget` `pypi` `python` `cpp` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `hugo` `docs` | [`spec/project-types.json`][project-types] | | workflow model | `release` `operational` | [`registry/repos.schema.json`][repos-schema] | | release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | | consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | diff --git a/spec/secrets.json b/spec/secrets.json index d5534a09..722b156c 100644 --- a/spec/secrets.json +++ b/spec/secrets.json @@ -1,6 +1,6 @@ { "$schema": "./secrets.schema.json", - "note": "Secrets the audit cross-checks. `baseline` applies to every fleet repo (the App-signed merge-bot runs everywhere). `mechanisms` are per-target/per-feature additions: a repo requires the baseline plus the mechanisms whose publish target (`targetMechanisms`) or feature workflow (`featureMechanisms`, detected by the mechanism's `workflowNeeds` action) it uses. Baseline secrets are implicit and are NOT repeated in a repo's registry `requiredSecrets`, which lists only the domain-specific additions. `typeMechanisms` are per-language requirements: a `csharp` or `python` repo must carry the mapped mechanism (codecov) regardless of opt-in. A configured secret that no applicable mechanism claims is a stale-secret finding; a present `forbids` secret is a defect.", + "note": "Secrets the audit cross-checks. `baseline` applies to every fleet repo (the App-signed merge-bot runs everywhere). `mechanisms` are per-target/per-feature additions: a repo requires the baseline plus the mechanisms its declared publish target (`targetMechanisms`) or declared type (`typeMechanisms`) maps to. All three mappings resolve from the registry entry rather than from workflow content: nothing reads a repo's Actions files to infer a mechanism, and `workflowNeeds` records what a mechanism needs to appear in a workflow for a human or agent reading the audit, rather than being a detector. `featureMechanisms` is shape-validated but claims nothing today, since the one feature it names (codecov) is claimed through `typeMechanisms` at build profile instead. Baseline secrets are implicit and are NOT repeated in a repo's registry `requiredSecrets`, which lists only the domain-specific additions. `typeMechanisms` are per-language requirements: a `csharp` or `python` repo must carry the mapped mechanism (codecov) regardless of opt-in. A configured secret that no applicable mechanism claims is a stale-secret finding; a present `forbids` secret is a defect. `environments`, where a repo carries it, lists the per-environment GitHub Environment secrets and variables its deploy needs. It is operator documentation rather than part of the mechanism audit: no tool reads it, because neither `spec/validate.py` nor `spec/audit.py` can enumerate an environment-scoped store, so a clean audit is not evidence that an environment is configured. `environmentSecrets` names what one environment carries and another does not, so a name audit does not read a single-environment credential as missing everywhere else.", "baseline": { "requires": ["CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"], "forbids": ["CODEGEN_APP_ID"], @@ -37,13 +37,22 @@ "workflowNeeds": ["codecov/codecov-action"], "stores": ["actions", "dependabot"], "note": "A csharp or python repo lists CODECOV_TOKEN in its registry requiredSecrets (unlike the implicit baseline secrets). Both stores: a workflow run triggered by a Dependabot PR reads the Dependabot secrets store, so without that copy the coverage upload silently skips on every bot PR. Coverage upload is report-only by default (fail_ci_if_error: false, so a Codecov hiccup never fails the gate). A repo may enforce a stricter threshold (e.g. 99%+) on top of that default." + }, + "deploy-ssh": { + "kind": "static-secret", + "requires": [], + "forbids": [], + "workflowNeeds": ["environment:", "IdentitiesOnly=yes"], + "stores": [], + "note": "A deploy to a filesystem on a host the project owns, reached over SSH. requires and stores are empty deliberately rather than for want of credentials: the key and the host values are per-environment GitHub Environment secrets and variables, which this file has no vocabulary for and neither validate.py nor audit.py can enumerate. Listing the names would force them into the repo's registry requiredSecrets, which the audit resolves against the repository actions store, so a correctly configured repo would report every one of them as missing. A repo declares them in its own environments block below instead. The key is confined at the far end by an authorized_keys forced command rooted at the deploy tree, so the workflow names no host path." } }, "targetMechanisms": { "nuget": "nuget-oidc", "pypi": "pypi-oidc", "docker": "docker-hub", - "github-release": null + "github-release": null, + "self-hosted": "deploy-ssh" }, "featureMechanisms": { "codecov": "codecov" diff --git a/spec/secrets.schema.json b/spec/secrets.schema.json index dfebcf34..d3f6451d 100644 --- a/spec/secrets.schema.json +++ b/spec/secrets.schema.json @@ -44,6 +44,22 @@ "typeMechanisms": { "type": "object", "additionalProperties": { "type": ["string", "null"] } + }, + "environments": { + "type": "object", + "additionalProperties": false, + "properties": { + "note": { "type": "string" }, + "names": { "type": "array", "items": { "type": "string" } }, + "secrets": { "type": "array", "items": { "type": "string" } }, + "variables": { "type": "array", "items": { "type": "string" } }, + "secretsNote": { "type": "string" }, + "variablesNote": { "type": "string" }, + "environmentSecrets": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } } + } + } } } } diff --git a/spec/type-model.md b/spec/type-model.md index d5214525..7d4db591 100644 --- a/spec/type-model.md +++ b/spec/type-model.md @@ -48,6 +48,14 @@ Language types carry the style and type-check requirements for their language, g - **cpp** - C/C++ present for style only. The check of record is **clang-format** (a shared config driving the editor, the CLI, and CI, a `parity.lang` arm), feeding the operational lint CI. Deeper semantic and static analysis is intentionally out of scope here - for a codegen or config repo the C++ is scaffolded and completed by its downstream toolchain (an ESPHome compile), which does the compilation-time checking, and clang-tidy would need a compile database the repo does not have. A repo's `.h` is read as C++ by context (Arduino/ESPHome), since the extension alone is ambiguous. +## Generators + +A **generator** type is what a repo builds its deliverable *with*, where the deliverable is not code: a static-site generator, a documentation builder. It is named for the generator (`hugo`) rather than for the transport that ships the result, because what a repo builds and where the result lands are separate axes. The destination lives in the registry `publish[]` entry (`{ target, mechanism }`), so a repo changes transport without changing type, and a second transport is a new **mechanism** rather than a new type. Baking the transport into the type is what makes the set explode combinatorially: one generator over two transports would otherwise need two types. + +There is no `static-site` to `hugo` hierarchy while the type has one member. Instead each check's `assert` is phrased without naming the generator wherever the requirement generalizes (the URL-contract gate and its length floor, the rendered output never committed, the generator pinned by version and digest, vendored-dependency provenance), and names it only where a generator-specific construct *is* the letter, such as a build flag. When a second generator joins the fleet, promoting the generic checks to a shared type is then a registry edit rather than a rewrite, which is the property the phrasing rule exists to preserve. Paying for that abstraction at one member would be the more expensive mistake. + +A generator type declares no `profiles`. Build versus lint-only is a depth of *language* presence, so a profile on a generator type would assert nothing, and `spec/validate.py` rejects a declared profile whose type does not define one. + ## Changing the type set carries review weight The set of types, their profiles, and each type's checks is governed, like the section and fidelity models.