diff --git a/TODO.md b/TODO.md index c4d10b2f..f3f0e555 100644 --- a/TODO.md +++ b/TODO.md @@ -24,17 +24,19 @@ The steps below are followed in order rather than sampled. ### The Prose Content Backlog -One pull request clearing prose findings, leading with [`catalog/snippets/`][snippets] because a non-conformant snippet seeds its violations into every repo that adopts it and the downstream repo is then flagged for content it was handed. +One pull request clearing the prose findings the hub's own docs and spec still carry, now that [`catalog/snippets/`][snippets] is clear. The snippets led because a non-conformant snippet seeds its violations into every repo that adopts it, and the downstream repo is then flagged for content it was handed. -**State** `ready`. **Touches** [`catalog/snippets/`][snippets] first, then the hub's docs and spec. **Cost** one hub edit per batch, hub-only, and a snippet fix reaches the fleet only as repos re-adopt. +**State** `ready`. **Touches** the hub's docs and spec. **Cost** one hub edit per batch, hub-only. -- **Clear the [#519][issue-519] prose backlog, snippets first.** The whole-tree figure moves as readily with a fix to the gate as with a fix to the prose, so it is re-measured rather than quoted. +- **Clear the remaining [#519][issue-519] prose backlog, outside the snippets.** The whole-tree figure moves as readily with a fix to the gate as with a fix to the prose, so it is re-measured rather than quoted. - **Blocked by** - Nothing. - **Issue** - [#519][issue-519], whose headline numbers are stale and whose four planned changes are two-thirds landed. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where `python3 scripts/prose_lint.py --summary` reports 557 violations across 45 files, and `catalog/snippets` alone reports 184 across 19. + - **Checked** - `develop` at `a6d7a4b` on 2026-08-07, where `python3 scripts/prose_lint.py --summary` reports 373 violations across 26 files, and `catalog/snippets` reports 0. The snippets sweep took the tree from 557 across 45, of which 184 across 19 were snippets. - **Open** - Whether the gate becomes a carried file rather than a hub-only one, which "Reducing the Carried Surface Further" asks from the other direction. - **Settled** - `comment-wrap` and `comment-case` are in `DEFAULT_RULES` and `reports/` is exempt as a generated tree, which is why the figures differ from the 668 and 119 the issue records. - **Settled** - `sentence-split` is defined but excluded from `DEFAULT_RULES`, so a sweep never reports it and a wrapped sentence in Markdown prose is not a finding. + - **Settled** - The three largest files are not snippets and are Python comments rather than prose, being [`spec/audit.py`][audit] at 99, [`gh-write-guard.py`][write-guard] at 52, and [`spec/validate.py`][validate] at 41 when measured before the sweep. + - **Settled** - A comment opening on a lowercase identifier is the bulk of what `comment-case` still reports, and the rule intends those restructured rather than exempted. The exemptions the snippets sweep added cover a commented-out key and a definition label, and nothing wider. ### Two Checks That Read What a Claim Points At @@ -510,5 +512,6 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i [snippets]: ./catalog/snippets/ [standup]: ./STANDUP.md [type-model]: ./spec/type-model.md +[validate]: ./spec/validate.py [workflows]: ./catalog/snippets/workflows/ [write-guard]: ./host-setup/agent-safety/gh-write-guard.py diff --git a/catalog/snippets/configs/codecov.yml b/catalog/snippets/configs/codecov.yml index 730d38ae..50cbf1c2 100644 --- a/catalog/snippets/configs/codecov.yml +++ b/catalog/snippets/configs/codecov.yml @@ -1,4 +1,5 @@ -# Codecov configuration. Codecov's coverage statuses are advisory - they report and trend coverage, never gating a PR. +# Codecov configuration. +# Codecov's coverage statuses are advisory: they report and trend coverage, never gating a PR. # - informational: true makes project and patch statuses advisory (always pass), so a coverage delta never blocks a PR. # - Distinct from the upload step's fail_ci_if_error: false, which only guards upload errors. # - A repo may override this to enforce a coverage threshold. @@ -11,6 +12,7 @@ coverage: default: informational: true -# Exclude code intentionally not unit-tested from the coverage denominator. Add the repo's own paths. +# Exclude code intentionally not unit-tested from the coverage denominator. +# Add the repo's own paths. # ignore: # - "Sandbox/**" diff --git a/catalog/snippets/configs/dependabot.yml b/catalog/snippets/configs/dependabot.yml index d847fba9..ca644152 100644 --- a/catalog/snippets/configs/dependabot.yml +++ b/catalog/snippets/configs/dependabot.yml @@ -1,12 +1,14 @@ # Reference: multi-ecosystem dual-target Dependabot config for a code-shipping repo. # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # -# Every ecosystem appears twice, once per target branch (main and develop), so both branches stay -# current on dependency versions independently of the develop -> main release cadence. Push-distribution -# channels (HACS, distros that pull main) consume main directly, so main must get bumps without waiting -# for the next release. The merge-bot dispatches the merge method per base ref (squash on develop, merge -# on main). develop is strictly forward-only; each branch absorbs its own PRs. Security (CVE) PRs always -# target the default branch regardless of target-branch. Keep only the ecosystems the repo actually ships. +# Every ecosystem appears twice, once per target branch (main and develop). +# Both branches therefore stay current on dependency versions independently of the release cadence. +# Push-distribution channels (HACS, distros that pull main) consume main directly. +# The main branch must therefore get bumps without waiting for the next release. +# The merge-bot dispatches the merge method per base ref (squash on develop, merge on main). +# Because develop is strictly forward-only, each branch absorbs its own PRs. +# Security (CVE) PRs always target the default branch regardless of target-branch. +# Keep only the ecosystems the repo actually ships. version: 2 updates: @@ -50,7 +52,8 @@ updates: patterns: - "*" - # uv directory points at the Python package root (e.g. /PackageName); adjust per repo layout. + # The uv directory points at the Python package root (e.g. /PackageName). + # Adjust it per repo layout. - package-ecosystem: "uv" target-branch: "main" directory: "/PythonPackage" diff --git a/catalog/snippets/configs/docker-hub-readme.md b/catalog/snippets/configs/docker-hub-readme.md index 4e17500a..3c4c7da4 100644 --- a/catalog/snippets/configs/docker-hub-readme.md +++ b/catalog/snippets/configs/docker-hub-readme.md @@ -4,8 +4,8 @@ Container image for the ProjectTemplate Console app. ## Image Tags -- `latest` - the current stable release, built from `main`. -- `develop` - the current prerelease, built from `develop`. +- **`latest`** - the current stable release, built from `main`. +- **`develop`** - the current prerelease, built from `develop`. - Immutable version tags (NBGV SemVer2): `X.Y.Z` for stable releases, `X.Y.Z-g` for develop prereleases. ## Usage diff --git a/catalog/snippets/devcontainer/python/post-create.sh b/catalog/snippets/devcontainer/python/post-create.sh index bfd59a8a..2f1a34d6 100755 --- a/catalog/snippets/devcontainer/python/post-create.sh +++ b/catalog/snippets/devcontainer/python/post-create.sh @@ -1,31 +1,30 @@ #!/usr/bin/env bash set -Eeuo pipefail -# Install uv (Astral) for the Python project. Idempotent - re-running -# overwrites in place. The installer drops the binary in $HOME/.local/bin and -# updates user shell init to add it to PATH for new shells; we add it to the -# current PATH explicitly so the rest of this script can invoke `uv` without a -# hard-coded path. +# Install uv (Astral) for the Python project. +# It is idempotent, since re-running overwrites in place. +# The installer drops the binary in $HOME/.local/bin. +# It also updates user shell init to add it to PATH for new shells. +# We add it to the current PATH explicitly so the rest of this script can invoke `uv`. # -# uv is pinned to a specific version (via the version-prefixed install URL, -# https://astral.sh/uv//install.sh) so a compromised or broken -# upstream `latest` script cannot silently change what runs on contributors' -# machines and CI runners. Bump UV_VERSION when you've reviewed release notes. +# The uv version is pinned via the version-prefixed install URL. +# That URL is https://astral.sh/uv//install.sh. +# A broken or compromised upstream `latest` script therefore cannot silently change what runs. +# Bump UV_VERSION when you've reviewed release notes. # -# We re-install when uv is missing OR when the installed version doesn't -# match the pin. The latter handles the case where a contributor (or a -# previous run with a different pin) left a different uv version on PATH - -# the pin is what's reproducible and what the lockfile is generated against. +# We re-install when uv is missing OR when the installed version doesn't match the pin. +# The latter handles a contributor or an earlier pin leaving another uv version on PATH. +# The pin is what's reproducible and what the lockfile is generated against. UV_VERSION="0.11.8" installed_uv_version="" if command -v uv >/dev/null 2>&1; then installed_uv_version="$(uv --version | awk '{print $2}')" fi if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then - # Download the pinned installer to a temp file first instead of piping - # `curl ... | sh`. This produces a logged sha256 of exactly the bytes we - # ran, so a compromised installer leaves a forensic trail; it also lets - # a future change pin a known-good checksum (set EXPECTED_SHA below). + # Download the pinned installer to a temp file first instead of piping `curl ... | sh`. + # This produces a logged sha256 of exactly the bytes we ran. + # A compromised installer therefore leaves a forensic trail. + # It also lets a future change pin a known-good checksum (set EXPECTED_SHA below). installer=$(mktemp -t uv-install.XXXXXX.sh) trap 'rm -f "$installer"' EXIT curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" -o "$installer" @@ -40,8 +39,8 @@ if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then export PATH="$HOME/.local/bin:$PATH" fi -# Pre-warm uv environment for PyPiLibrary if it exists. Guarded so this script -# is safe before PyPiLibrary lands in the repo. +# Pre-warm uv environment for PyPiLibrary if it exists. +# It is guarded so this script is safe before PyPiLibrary lands in the repo. if [[ -f PyPiLibrary/pyproject.toml ]]; then (cd PyPiLibrary && uv sync) fi diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index a8ca49bf..bb417de9 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -1,7 +1,7 @@ # Husky snippet -`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only** - CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo - kept fast with native tooling and no Docker. +`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only**: CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo. Native tooling and no Docker is what keeps it fast. Full linting (line endings, workflow YAML, Markdown, spelling) is **not** run in the hook. It runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`). Keeping the doc linters out of the hook is what keeps it simple. -A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in both `.gitattributes` (`.husky/pre-commit text eol=lf`) and `.editorconfig` (`[.husky/pre-commit] end_of_line = lf`) - a CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. +A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in both `.gitattributes` (`.husky/pre-commit text eol=lf`) and `.editorconfig` (`[.husky/pre-commit] end_of_line = lf`). A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index e9a75d5a..c29797a9 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -1,9 +1,11 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -# Local pre-commit: language formatting and style only (no Docker). Full lint runs in CI and the VS Code Lint tasks. +# Local pre-commit: language formatting and style only (no Docker). +# Full lint runs in CI and the VS Code Lint tasks. -# .NET: CSharpier + dotnet format style via Husky.Net. A Python repo runs ruff here instead. +# .NET: CSharpier + dotnet format style via Husky.Net. +# A Python repo runs ruff here instead. if command -v dotnet >/dev/null 2>&1; then dotnet husky run fi diff --git a/catalog/snippets/vscode/README.md b/catalog/snippets/vscode/README.md index ffdc959e..94b04a3c 100644 --- a/catalog/snippets/vscode/README.md +++ b/catalog/snippets/vscode/README.md @@ -1,18 +1,18 @@ # VS Code Workspace Catalog -The shared `.code-workspace` set for the fleet: the standard extensions every repo recommends, the language-specific additions, and the settings that go with them. Each piece is a copyable JSON fragment in this directory - `base.jsonc` for the standard set, and `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` for the per-type additions. A repo's `.code-workspace` composes `base.jsonc` plus the fragments for the languages and targets it ships. Discovered from the fleet's workspace files. +The shared `.code-workspace` set for the fleet: the standard extensions every repo recommends, the language-specific additions, and the settings that go with them. Each piece is a copyable JSON fragment in this directory. `base.jsonc` carries the standard set, and `dotnet.jsonc`, `python.jsonc`, and `docker.jsonc` carry the per-type additions. A repo's `.code-workspace` composes `base.jsonc` plus the fragments for the languages and targets it ships. Discovered from the fleet's workspace files. ## Standard Extensions (every repo) -- `davidanson.vscode-markdownlint` - Markdown lint, sharing `.markdownlint-cli2.jsonc`. -- `streetsidesoftware.code-spell-checker` - cSpell, sharing `cspell.json`. -- `editorconfig.editorconfig` - applies `.editorconfig`. -- `yzhang.markdown-all-in-one` - Markdown editing and the auto-generated Table of Contents. -- `fanaticpythoner.better-todo-tree` - surfaces TODO/FIXME markers. -- `github.vscode-github-actions` - GitHub Actions authoring. -- `arahata.linter-actionlint` - actionlint for workflow YAML. -- `timonwong.shellcheck` - shellcheck for shell scripts. -- `anthropic.claude-code` - the coding agent. +- **`davidanson.vscode-markdownlint`** - Markdown lint, sharing `.markdownlint-cli2.jsonc`. +- **`streetsidesoftware.code-spell-checker`** - cSpell, sharing `cspell.json`. +- **`editorconfig.editorconfig`** - applies `.editorconfig`. +- **`yzhang.markdown-all-in-one`** - Markdown editing and the auto-generated Table of Contents. +- **`fanaticpythoner.better-todo-tree`** - surfaces TODO/FIXME markers. +- **`github.vscode-github-actions`** - GitHub Actions authoring. +- **`arahata.linter-actionlint`** - actionlint for workflow YAML. +- **`timonwong.shellcheck`** - shellcheck for shell scripts. +- **`anthropic.claude-code`** - the coding agent. ## Language and Target Additions @@ -22,10 +22,10 @@ The shared `.code-workspace` set for the fleet: the standard extensions every re ## Settings -- **Table of Contents**: `"markdown.extension.toc.levels": "2..3"` - the Markdown All in One extension includes H2 and H3 headings and updates the TOC on save. -- **Format on save** per language: C# via `csharpier.csharpier-vscode`; Python via `charliermarsh.ruff` with import organization. +- **Table of Contents**: `"markdown.extension.toc.levels": "2..3"`. The Markdown All in One extension includes H2 and H3 headings and updates the TOC on save. +- **Format on save** per language: C# via `csharpier.csharpier-vscode`, Python via `charliermarsh.ruff` with import organization. - **cSpell and markdownlint** read the repo's `cspell.json` and `.markdownlint-cli2.jsonc` (linter parity). -- Trim trailing whitespace except in Markdown and plaintext; sign off commits (`git.alwaysSignOff`). +- Trim trailing whitespace except in Markdown and plaintext, and sign off commits (`git.alwaysSignOff`). ## Composing a Workspace diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index d42e1e5e..b4120282 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,18 +1,18 @@ # Workflow snippets -The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here** - this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees; the audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. +The reusable build/publish workflow tasks a code-shipping repo runs. They are **inert reference here**: this repo is source-only and keeps just the orchestrator set (`test-pull-request`, `publish-release`, `validate-task`, `merge-bot-pull-request`) in `.github/workflows/`. Each file below is the canonical implementation of one or more `WORKFLOW.md` guarantees. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | | `build-release-task.yml` | Multi-target release orchestrator: get-version, validate-release, github-release plus per-target build jobs | D3, D4, D5, D6 | | `get-version-task.yml` | NBGV version/tag computation (reusable) | D3 | | `publish-plan-task.yml` | Single-source release-gate decision (publish? stable?) reused by every publish-release job | D4 | -| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6; section 6 Console walkthrough | -| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6; section 6 NuGet walkthrough | -| `build-pypilibrary-task.yml` | Build PyPI package; publish split to an OIDC job | D3.4, D4, D7.2; section 6 PyPI walkthrough | -| `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 | +| `build-executable-task.yml` | Console/executable per-runtime publish, aggregate to one release asset | D5, D6, and section 6 Console walkthrough | +| `build-nugetlibrary-task.yml` | Build + `dotnet nuget push` (OIDC), upload release asset | D3.4, D4.4, D6, and section 6 NuGet walkthrough | +| `build-pypilibrary-task.yml` | Build PyPI package, with publishing split to an OIDC job | D3.4, D4, D7.2, and section 6 PyPI walkthrough | +| `build-docker-task.yml` | Multi-arch image build + push, registry layer cache | D4.4, D6, D9.4, and section 6 Docker walkthrough | +| `build-datebadge-task.yml` | BYOB date/last-build badge on the default branch | D4, and section 3 Release Model | +| `publish-docker-readme-task.yml` | Push the size-limited Docker Hub overview | D2.4, and 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 | diff --git a/catalog/snippets/workflows/build-datebadge-task.yml b/catalog/snippets/workflows/build-datebadge-task.yml index 8042b526..3d4c3e21 100644 --- a/catalog/snippets/workflows/build-datebadge-task.yml +++ b/catalog/snippets/workflows/build-datebadge-task.yml @@ -1,7 +1,7 @@ name: Build BYOB date badge task -# Caller-gated: the publisher invokes this only when main is published - the badge has no per-branch context, it tracks -# the last main build. +# Caller-gated: the publisher invokes this only when main is published. +# The badge has no per-branch context, so it tracks the last main build. on: workflow_call: diff --git a/catalog/snippets/workflows/build-docker-task.yml b/catalog/snippets/workflows/build-docker-task.yml index 6d5a709c..0de5e291 100644 --- a/catalog/snippets/workflows/build-docker-task.yml +++ b/catalog/snippets/workflows/build-docker-task.yml @@ -13,13 +13,15 @@ on: required: false type: string default: '' - # Logical branch driving config and tags (main => Release/latest, otherwise Debug/develop). Required (no - # github.ref_name fallback): the publisher builds develop from a main-ref run, so a fallback would mistag it. + # Logical branch driving config and tags, where main gives Release/latest and anything else Debug/develop. + # It is required, with no github.ref_name fallback. + # The publisher builds develop from a main-ref run, so a fallback would mistag it. branch: required: true type: string - # Smoke mode: build linux/amd64 only (no QEMU/arm64), never push, and skip the shared registry cache-to so PR - # builds don't pollute the release buildcache. Used for fast PR feedback. + # Smoke mode: build linux/amd64 only, with no QEMU/arm64, and never push. + # It also skips the shared registry cache-to so PR builds don't pollute the release buildcache. + # It is used for fast PR feedback. smoke: required: false type: boolean @@ -49,7 +51,8 @@ jobs: with: ref: ${{ inputs.ref }} - # arm64 is non-native on the amd64 runner, so install its QEMU emulator only when the build includes it. + # The arm64 target is non-native on the amd64 runner. + # Install its QEMU emulator only when the build includes it. - name: Setup QEMU step if: ${{ contains(env.PLATFORMS, 'arm64') }} uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 @@ -61,8 +64,8 @@ jobs: with: platforms: ${{ env.PLATFORMS }} - # Always login (even for smoke) for the higher Docker rate limits on pulls/cache reads; fork PRs without the - # secrets can't run the Docker smoke build, acceptable for same-repo PRs. + # Always login, even for smoke, for the higher Docker rate limits on pulls and cache reads. + # Fork PRs without the secrets can't run the Docker smoke build, which is acceptable for same-repo PRs. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: @@ -79,8 +82,8 @@ jobs: docker.io/ptr727/projecttemplate:${{ inputs.branch == 'main' && 'latest' || 'develop' }} docker.io/ptr727/projecttemplate:${{ needs.get-version.outputs.SemVer2 }} platforms: ${{ env.PLATFORMS }} - # Read both branches' caches (near-identical layers) but write only this branch's tag, and only when - # pushing, so the weekly main+develop matrix legs don't overwrite one shared cache. + # Read both branches' caches, whose layers are near-identical, but write only this branch's tag. + # Write only when pushing, so the weekly main+develop matrix legs don't overwrite one shared cache. cache-from: | type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop diff --git a/catalog/snippets/workflows/build-executable-task.yml b/catalog/snippets/workflows/build-executable-task.yml index 20582689..9f09c599 100644 --- a/catalog/snippets/workflows/build-executable-task.yml +++ b/catalog/snippets/workflows/build-executable-task.yml @@ -8,13 +8,13 @@ on: required: false type: string default: '' - # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the - # develop leg of the publisher's matrix isn't mislabeled. + # Logical branch driving build configuration, where `main` gives Release and anything else Debug. + # It is required, with no fallback, so the develop leg of the publisher's matrix isn't mislabeled. branch: required: true type: string - # Smoke mode: build a runtime subset (linux-x64 + win-x64) instead of the full matrix and skip the zip / - # artifact aggregation, for fast PR feedback. + # Smoke mode: build a runtime subset (linux-x64 + win-x64) instead of the full matrix. + # It also skips the zip and artifact aggregation, for fast PR feedback. smoke: required: false type: boolean @@ -65,8 +65,8 @@ jobs: -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} - # Branch-suffixed so both branches can build in one run without colliding. Skipped on smoke: the aggregation - # job is `!smoke`, so the per-runtime output would have no consumer. + # Branch-suffixed so both branches can build in one run without colliding. + # It is skipped on smoke, since the aggregation job is `!smoke` and the per-runtime output would have no consumer. - name: Upload matrix build artifacts step if: ${{ !inputs.smoke }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -76,8 +76,8 @@ jobs: # Consumed within this run by the aggregation job; minimize artifact storage. retention-days: 1 - # Smoke builds only need the per-runtime compile to succeed, so skip the zip aggregation; the release job never - # runs on smoke, so no `release-asset-*` artifact is needed. + # Smoke builds only need the per-runtime compile to succeed, so skip the zip aggregation. + # The release job never runs on smoke, so no `release-asset-*` artifact is needed. upload-build-artifacts: name: Upload matrix build artifacts job if: ${{ !inputs.smoke }} @@ -96,8 +96,8 @@ jobs: - name: Zip build output step run: 7z a -t7z ${{ runner.temp }}/Console.7z ${{ runner.temp }}/publish/* - # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job - # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. + # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job collects. + # It is branch-suffixed so the publisher can build both branches in one run without colliding on the name. - name: Upload build artifacts step uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/catalog/snippets/workflows/build-nugetlibrary-task.yml b/catalog/snippets/workflows/build-nugetlibrary-task.yml index 8bcfd0d7..8d550d36 100644 --- a/catalog/snippets/workflows/build-nugetlibrary-task.yml +++ b/catalog/snippets/workflows/build-nugetlibrary-task.yml @@ -13,13 +13,14 @@ on: required: false type: string default: '' - # Logical branch driving build configuration (`main` => Release, else Debug). Required (no fallback) so the - # develop leg of the publisher's matrix isn't mislabeled. + # Logical branch driving build configuration, where `main` gives Release and anything else Debug. + # It is required, with no fallback, so the develop leg of the publisher's matrix isn't mislabeled. branch: required: true type: string - # Smoke mode: build for validation only and skip the release-asset zip/upload. A PR smoke run has no consumer - # for the artifact (the github-release job is gated `!smoke`), so uploading it just burns artifact storage. + # Smoke mode: build for validation only and skip the release-asset zip and upload. + # A PR smoke run has no consumer for the artifact, since the github-release job is gated `!smoke`. + # Uploading it would just burn artifact storage. smoke: required: false type: boolean @@ -76,9 +77,9 @@ jobs: if: ${{ !inputs.smoke }} run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* - # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job - # collects. Branch-suffixed so the publisher can build both branches in one run without colliding on the name. - # Skipped on smoke: the github-release job is `!smoke`, so nothing would consume it. + # GitHub-release asset, uploaded under the `release-asset--*` pattern that the `github-release` job collects. + # It is branch-suffixed so the publisher can build both branches in one run without colliding on the name. + # It is skipped on smoke, since the github-release job is `!smoke` and nothing would consume it. - name: Upload build artifacts step if: ${{ !inputs.smoke }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/catalog/snippets/workflows/build-pypilibrary-task.yml b/catalog/snippets/workflows/build-pypilibrary-task.yml index 8efb9640..6a43dc1d 100644 --- a/catalog/snippets/workflows/build-pypilibrary-task.yml +++ b/catalog/snippets/workflows/build-pypilibrary-task.yml @@ -1,8 +1,9 @@ name: Build PyPI library task -# Reusable workflow that only builds the PyPI wheel + sdist and uploads them as a run artifact; it does not -# publish. Publishing lives in publish-release.yml so id-token:write is granted only at that entry point and not -# propagated to every caller (e.g. the PR smoke build). +# Reusable workflow that only builds the PyPI wheel + sdist and uploads them as a run artifact. +# It does not publish. +# Publishing lives in publish-release.yml, so id-token:write is granted only at that entry point. +# It is therefore not propagated to every caller, such as the PR smoke build. on: workflow_call: @@ -12,13 +13,14 @@ on: required: false type: string default: '' - # Logical branch driving the PEP 440 version (develop => .dev0 prerelease, otherwise plain release). Required; the - # orchestrator passes it explicitly. + # Logical branch driving the PEP 440 version, where develop gives a .dev0 prerelease and anything else a plain release. + # It is required, and the orchestrator passes it explicitly. branch: required: true type: string - # Smoke mode: build for validation only and skip the artifact upload. A PR smoke run has no consumer for the - # wheel/sdist (the publish-pypi job runs only on a real publish), so uploading it just burns artifact storage. + # Smoke mode: build for validation only and skip the artifact upload. + # A PR smoke run has no consumer for the wheel/sdist, since the publish-pypi job runs only on a real publish. + # Uploading it would just burn artifact storage. smoke: required: false type: boolean @@ -58,8 +60,8 @@ jobs: - name: Setup uv step uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - # Pin uv to the same version as the devcontainer (UV_VERSION) so CI and local resolves cannot drift; bump in - # lockstep. + # Pin uv to the same version as the devcontainer (UV_VERSION) so CI and local resolves cannot drift. + # Bump the two in lockstep. version: "0.11.8" enable-cache: true cache-dependency-glob: "PyPiLibrary/uv.lock" @@ -79,8 +81,9 @@ jobs: - name: Run pytest step run: uv run pytest - # PEP 440 version: develop -> M.N.P.B.dev0 (prerelease), else M.N.P.B. The NBGV git height sits in the release - # segment, so develop's normally exceeds main's and `pip install --pre` picks the dev build over the main release. + # PEP 440 version: develop gives M.N.P.B.dev0 as a prerelease, and anything else gives M.N.P.B. + # The NBGV git height sits in the release segment, so develop's normally exceeds main's. + # `pip install --pre` therefore picks the dev build over the main release. - name: Compute PyPI version step id: pypiver run: | diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml index cf1159d7..c10a99f0 100644 --- a/catalog/snippets/workflows/build-release-task.yml +++ b/catalog/snippets/workflows/build-release-task.yml @@ -23,19 +23,21 @@ on: required: false type: string default: '' - # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because each - # publish run builds a single branch (the trigger ref), so a silent fallback would mislabel the build. + # Logical branch driving config / tags / prerelease for every target. + # It is required, with no fallback, because each publish run builds a single branch (the trigger ref). + # A silent fallback would therefore mislabel the build. branch: required: true type: string - # Smoke mode: reduced, never-published build for fast PR feedback. Forwarded to every target and hard-disables - # every push below, so a smoke run can never publish regardless of the publish flags. + # Smoke mode: a reduced, never-published build for fast PR feedback. + # It is forwarded to every target and hard-disables every push below. + # A smoke run can therefore never publish regardless of the publish flags. smoke: required: false type: boolean default: false - # Per-target presence gates. Default true (build everything); a PR smoke run sets these from the paths-filter - # so only changed targets build. + # Per-target presence gates, defaulting to true so everything builds. + # A PR smoke run sets these from the paths-filter so only changed targets build. enable_docker: required: false type: boolean @@ -52,8 +54,9 @@ on: required: false type: boolean default: true - # Set false for a repo that produces no release-asset-* files (e.g. Docker-only): the release is then just the - # tag + source zip + README + LICENSE; the artifact download is skipped and the unmatched-files guard relaxes. + # Set false for a repo that produces no release-asset-* files, such as a Docker-only one. + # The release is then just the tag + source zip + README + LICENSE. + # The artifact download is skipped and the unmatched-files guard relaxes. expect_release_assets: required: false type: boolean @@ -68,10 +71,12 @@ jobs: with: ref: ${{ inputs.ref }} - # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails - # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch - # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip - # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. + # Entry gate: validate branch<->version consistency once, before the build jobs. + # An NBGV mis-classification then fails fast instead of after building and publishing. + # The main branch must be a public release, carrying no prerelease '-'. + # Every other branch must carry a prerelease '-', which guards a develop leg being published as stable. + # Strip '+buildmetadata' first, since a '-' there is legitimate. + # Only a '-' in the core/prerelease segment marks a prerelease. validate-release: name: Validate release version job needs: [get-version] @@ -115,8 +120,9 @@ jobs: # Skip the release-asset upload on smoke (nothing consumes it on a PR). smoke: ${{ inputs.smoke }} - # PyPI publishing happens in `publish-release.yml`, not here, so `id-token: write` is only granted at the - # entry-point job; this workflow just builds and uploads the artifact for that job to download. + # PyPI publishing happens in `publish-release.yml` rather than here. + # `id-token: write` is therefore only granted at the entry-point job. + # This workflow just builds and uploads the artifact for that job to download. build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} @@ -142,12 +148,14 @@ jobs: branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} - # Docker is the terminal registry push, so it must never push on a partial run. It needs every other build and - # guards with `!failure() && !cancelled()`: a *failed* build skips docker (no build, no push), while a *skipped* - # build - a target disabled on a smoke PR, or one this repo does not vendor - does not, so docker still builds - # the changed target on smoke. Plain `needs` cannot express this (a skipped need skips the dependent). The - # github-release job reaches the same intent more simply because it only runs on a publish (`!inputs.smoke`), - # where nothing is disabled. (A repo pushing a package alongside docker would need a build/publish split.) + # Docker is the terminal registry push, so it must never push on a partial run. + # It needs every other build and guards with `!failure() && !cancelled()`. + # A *failed* build skips docker, so there is no build and no push. + # A *skipped* build does not, so docker still builds the changed target on smoke. + # A skipped build is a target disabled on a smoke PR, or one this repo does not vendor. + # Plain `needs` cannot express this, since a skipped need skips the dependent. + # The github-release job reaches the same intent more simply, because it only runs on a publish (`!inputs.smoke`), where nothing is disabled. + # (A repo pushing a package alongside docker would need a build/publish split.) build-docker: name: Build Docker job if: ${{ inputs.enable_docker && !failure() && !cancelled() }} @@ -178,11 +186,11 @@ jobs: with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by - # deleting the target, not `enable_*: false` (a skipped `needs` job would skip this release job too). The release - # step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match; a repo that drops - # every file-producing target (e.g. a Docker-only repo, whose release carries only source zip + README + LICENSE) - # relaxes that guard. + # Collect assets by the `release-asset--*` pattern so this step is target-agnostic. + # Subset releases by deleting the target rather than by `enable_*: false`, since a skipped `needs` job would skip this release job too. + # The release step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match. + # A repo that drops every file-producing target relaxes that guard. + # A Docker-only repo is the case, its release carrying only the source zip + README + LICENSE. - name: Download release asset artifacts step if: ${{ inputs.expect_release_assets }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -191,8 +199,8 @@ jobs: merge-multiple: true path: ./Publish - # The weekly publisher re-runs even with no new commits, so the version may already be released. Skip the release - # step when a release for this tag already exists to avoid a no-op republish. + # The weekly publisher re-runs even with no new commits, so the version may already be released. + # Skip the release step when a release for this tag already exists, to avoid a no-op republish. - name: Check for existing release step id: release-exists env: @@ -214,10 +222,10 @@ jobs: # `target_commitish` must be set explicitly: otherwise GitHub's REST API tags the release on the default branch. # Pin it to `GitCommitId` so the tag is on the exact built commit, consistent with the SemVer2 tag and artifacts. # Skip when the release already exists, but always let a manual `workflow_dispatch` through to refresh it. - # Every release (any branch, any target) is a tag on the built commit plus the auto-attached source zip, README, - # and LICENSE; targets amend it by uploading `release-asset-*` files (binaries/packages) or pushing elsewhere - # (image/registry). `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or - # misnamed; a no-file-target repo relaxes it (see download step). + # Every release, on any branch and for any target, is a tag on the built commit plus the auto-attached source zip, README, and LICENSE. + # Targets amend it by uploading `release-asset-*` files (binaries/packages) or by pushing elsewhere (image/registry). + # `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or misnamed. + # A no-file-target repo relaxes it (see download step). - name: Create GitHub release step if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 @@ -232,16 +240,17 @@ jobs: README.md ./Publish/* - # Surgical cleanup at the point of consumption: the release-asset--* transfer artifacts now have durable - # copies on the release, so delete them by exact pattern to free the storage quota - scoped to this branch's - # assets, leaving diagnostics and any other artifacts. Gated to the same condition as the create step so it only - # deletes when a release was actually created/refreshed this run; on a skipped create (existing tag, no new - # commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. Needs the caller to - # grant `actions: write` (publish-release's publish job does). + # Surgical cleanup at the point of consumption, since the release-asset--* transfer artifacts now have durable copies on the release. + # Delete them by exact pattern to free the storage quota. + # The pattern is scoped to this branch's assets, leaving diagnostics and any other artifacts. + # It is gated to the same condition as the create step, so it only deletes when a release was actually created or refreshed this run. + # On a skipped create (an existing tag, no new commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. + # It needs the caller to grant `actions: write`, which publish-release's publish job does. - name: Delete consumed release asset artifacts step if: ${{ inputs.expect_release_assets && (steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch') }} - # Best-effort: the release is already published, so a listing/delete hiccup must never red the job; the - # retention-days: 1 backstop reaps anything missed. Deletes every matching id (a rerun can upload duplicates). + # Best-effort, since the release is already published, so a listing or delete hiccup must never red the job. + # The retention-days: 1 backstop reaps anything missed. + # It deletes every matching id, because a rerun can upload duplicates. continue-on-error: true env: GH_TOKEN: ${{ github.token }} diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml index e5a14845..62486520 100644 --- a/catalog/snippets/workflows/check-upstream-version-task.yml +++ b/catalog/snippets/workflows/check-upstream-version-task.yml @@ -1,16 +1,19 @@ name: Check upstream version task -# Skeleton for a wrapper repo tracking an upstream release: a resolver prints the upstream version(s) as a JSON object -# of name -> version (one key, or N for a multi-component pin), written to a committed state file beside version.json, -# and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges. Call from a scheduled entry-point -# workflow; matrix only the branches that ship the version (a CI-only version uses ["develop"]). +# Skeleton for a wrapper repo tracking an upstream release. +# A resolver prints the upstream version(s) as a JSON object of name -> version, one key or N for a multi-component pin. +# That object is written to a committed state file beside version.json. +# It then opens a rolling App-signed bump PR per branch that the merge-bot auto-merges. +# Call it from a scheduled entry-point workflow. +# Matrix only the branches that ship the version, since a CI-only version uses ["develop"]. on: workflow_call: inputs: resolver-command: - # Single-version wrappers print {"version":"X"}; multi-component wrappers print one - # key per pinned upstream component, e.g. {"esphome":"2026.6.2","device_builder":"1.0.12"}. + # Single-version wrappers print {"version":"X"}. + # Multi-component wrappers print one key per pinned upstream component. + # An example is {"esphome":"2026.6.2","device_builder":"1.0.12"}. description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout. required: true type: string @@ -25,8 +28,8 @@ on: type: string default: '["main", "develop"]' bump-branch-prefix: - # merge-bot-pull-request.yml hard-codes the head refs upstream-version-main/-develop; keep this - # default unless you also update the merge-upstream-version job, or auto-merge will not fire. + # The merge-bot-pull-request.yml workflow hard-codes the head refs upstream-version-main/-develop. + # Keep this default unless you also update the merge-upstream-version job, or auto-merge will not fire. description: Head-branch prefix; must match the merge-bot's upstream-version- head refs. required: false type: string @@ -61,8 +64,8 @@ jobs: ref: ${{ matrix.branch }} token: ${{ steps.app-token.outputs.token }} - # Normalize the resolved JSON (sorted keys) so the committed file and its diff are stable; the old-vs-new key - # diff drives the PR title/body, naming only components that moved. + # Normalize the resolved JSON with sorted keys, so the committed file and its diff are stable. + # The old-vs-new key diff drives the PR title/body, naming only components that moved. - name: Resolve upstream version step id: resolve env: @@ -71,8 +74,9 @@ jobs: run: | set -Eeuo pipefail - # Require a non-empty JSON object of single-line name -> version strings; a CR/LF would corrupt the - # single-line GITHUB_OUTPUT, so reject it here instead of committing unconsumable state. + # Require a non-empty JSON object of single-line name -> version strings. + # A CR/LF would corrupt the single-line GITHUB_OUTPUT. + # Reject it here instead of committing unconsumable state. raw="$(bash -c "$RESOLVER_COMMAND")" if ! new="$(printf '%s' "$raw" | jq -S '.' 2>/dev/null)" \ || [ "$(printf '%s' "$new" | jq -r 'type == "object" and length > 0 and all(.[]; type == "string" and (test("[\r\n]") | not)) and (keys | all(test("[\r\n]") | not))')" != "true" ]; then @@ -80,25 +84,27 @@ jobs: exit 1 fi - # Missing, non-JSON, or non-object state => empty object, so the first run and any - # unusable prior file both diff cleanly against the resolved object instead of failing - # (a valid-JSON-but-non-object file would otherwise break the `$old + $new` union below). + # Missing, non-JSON, or non-object state becomes an empty object. + # The first run and any unusable prior file then both diff cleanly against the resolved object instead of failing. + # A valid-JSON-but-non-object file would otherwise break the `$old + $new` union below. if [ -f "$STATE_FILE" ] && old="$(jq -S 'if type == "object" then . else empty end' "$STATE_FILE" 2>/dev/null)" && [ -n "$old" ]; then :; else old='{}'; fi - # Write the canonical state file as CRLF (jq emits LF) to match .editorconfig's .json - # rule. Unchanged content => no diff => no PR. + # Write the canonical state file as CRLF, since jq emits LF, to match .editorconfig's .json rule. + # Unchanged content means no diff, and therefore no PR. printf '%s\n' "$new" | sed 's/$/\r/' > "$STATE_FILE" - # Diff across the union of old+new keys so an added, moved, or removed key is all caught; - # removals carry a null .new. These drive the PR title/body (only keys that moved). + # Diff across the union of old+new keys so an added, moved, or removed key is all caught. + # Removals carry a null .new. + # These drive the PR title/body, naming only the keys that moved. changed="$(jq -n --argjson old "$old" --argjson new "$new" ' [ (($old + $new) | keys[]) | { key: ., new: $new[.] } | select($old[.key] != .new) ]')" summary="$(printf '%s' "$changed" | jq -r ' map(if .new == null then "\(.key) removed" else "\(.key) to \(.new)" end) | join(", ")')" - # Title: a canonicalization-only change (state reserialized, no key moved) when the prior - # file was valid-but-differently-formatted; the trivial single-version case renders bare; - # otherwise name each moved component. + # Title: a canonicalization-only change, meaning the state was reserialized with no key moved. + # That happens when the prior file was valid but differently formatted. + # The trivial single-version case renders bare. + # Otherwise name each moved component. if [ "$(printf '%s' "$changed" | jq 'length == 0')" = "true" ]; then title="Canonicalize upstream version state file" elif [ "$(printf '%s' "$new" | jq -r 'keys == ["version"]')" = "true" ]; then diff --git a/catalog/snippets/workflows/get-version-task.yml b/catalog/snippets/workflows/get-version-task.yml index 2d0321ca..066c3fe0 100644 --- a/catalog/snippets/workflows/get-version-task.yml +++ b/catalog/snippets/workflows/get-version-task.yml @@ -3,8 +3,9 @@ name: Get version information task on: workflow_call: inputs: - # Git ref to check out and version. Empty falls back to the caller's default checkout ref (`github.ref`); the - # publisher passes an explicit branch so a scheduled run can still compute versions for `develop`. + # Git ref to check out and version. + # Empty falls back to the caller's default checkout ref (`github.ref`). + # The publisher passes an explicit branch so a scheduled run can still compute versions for `develop`. ref: required: false type: string @@ -47,14 +48,16 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: 0 - # nbgv is floated on @master: its tag stream lags master, so Dependabot tag-tracking would propose a downgrade. + # The nbgv action is floated on @master, because its tag stream lags master. + # Dependabot tag-tracking would otherwise propose a downgrade. # Revisit if dotnet/nbgv resumes regular tagged releases. - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master env: - # Version from the checked-out branch, not the CI ref. GITHUB_REF is reserved and a step env can't reliably - # override it (the runner re-injects the dispatch ref), so on a publish dispatched from the default branch NBGV - # would classify every leg as the public ref. IGNORE_GITHUB_REF makes NBGV ignore GITHUB_REF and use the - # checked-out branch, which each matrix leg already is. The validate-release gate backstops any misclassification. + # Version from the checked-out branch rather than the CI ref. + # GITHUB_REF is reserved and a step env can't reliably override it, since the runner re-injects the dispatch ref. + # On a publish dispatched from the default branch, NBGV would therefore classify every leg as the public ref. + # IGNORE_GITHUB_REF makes NBGV ignore GITHUB_REF and use the checked-out branch, which each matrix leg already is. + # The validate-release gate backstops any misclassification. IGNORE_GITHUB_REF: "true" diff --git a/catalog/snippets/workflows/publish-docker-readme-task.yml b/catalog/snippets/workflows/publish-docker-readme-task.yml index 1c80cec5..b91c77f6 100644 --- a/catalog/snippets/workflows/publish-docker-readme-task.yml +++ b/catalog/snippets/workflows/publish-docker-readme-task.yml @@ -1,21 +1,23 @@ name: Publish Docker Hub readme task -# Pushes the Docker Hub repository overview (Docker/README.md). Caller-gated: the publisher invokes this only when main -# is published - the overview has no per-branch context. The repository list is either passed directly (`repositories`) -# or derived from a manifest (`manifest` + `manifest-jq`), covering single- and multi-image repos without per-repo glue. +# Pushes the Docker Hub repository overview (Docker/README.md). +# Caller-gated, so the publisher invokes this only when main is published. +# The overview has no per-branch context. +# The repository list is either passed directly (`repositories`) or derived from a manifest (`manifest` + `manifest-jq`). +# That covers single- and multi-image repos without per-repo glue. # An optional transform step (e.g. m4) renders the readme before pushing, optionally after downloading a build artifact. on: workflow_call: inputs: - # Ref whose readme (and any transform sources) to publish; empty uses the caller's ref. The publisher passes - # `main` so the overview tracks the main release even when dispatched from another ref. + # Ref whose readme, and any transform sources, to publish, where empty uses the caller's ref. + # The publisher passes `main` so the overview tracks the main release even when dispatched from another ref. ref: required: false type: string default: '' - # JSON array of Docker Hub repositories to update, e.g. '["owner/image"]'. Leave empty to derive the list from a - # manifest instead (see `manifest`). + # JSON array of Docker Hub repositories to update, e.g. '["owner/image"]'. + # Leave empty to derive the list from a manifest instead (see `manifest`). repositories: required: false type: string @@ -30,8 +32,8 @@ on: required: false type: string default: '' - # Optional command that renders the readme before pushing (e.g. an m4 step that writes Docker/README.md). Empty - # pushes the committed file as-is. + # Optional command that renders the readme before pushing, such as an m4 step that writes Docker/README.md. + # Empty pushes the committed file as-is. transform-run: required: false type: string @@ -42,8 +44,8 @@ on: required: false type: string default: '' - # Readme file to push (the transform's output, or the committed file). Single-image repos with a root README pass - # './README.md'. + # Readme file to push, being the transform's output or the committed file. + # Single-image repos with a root README pass './README.md'. readme-filepath: required: false type: string @@ -51,8 +53,8 @@ on: jobs: - # Resolve the repository list once - either the static `repositories` input or a jq program over a manifest - so the - # publish matrix is the same shape for single- and multi-image repos and no caller hand-rolls its own derivation. + # Resolve the repository list once, from either the static `repositories` input or a jq program over a manifest. + # The publish matrix is then the same shape for single- and multi-image repos, and no caller hand-rolls its own derivation. get-repos: name: Get repository list job runs-on: ubuntu-latest @@ -61,9 +63,10 @@ jobs: steps: - # Enforce the input contract once so the downstream steps can trust it: the list comes from exactly one source - - # `repositories`, the `manifest` + `manifest-jq` pair, or neither (default to this repo). Silent fall-through to the - # default would otherwise mask caller mistakes (a half-filled manifest pair, or both sources passed at once). + # Enforce the input contract once so the downstream steps can trust it. + # The list comes from exactly one source: `repositories`, the `manifest` + `manifest-jq` pair, or neither, defaulting to this repo. + # Silent fall-through to the default would otherwise mask caller mistakes. + # A half-filled manifest pair, or both sources passed at once, are the cases. - name: Validate inputs step env: REPOSITORIES: ${{ inputs.repositories }} @@ -101,8 +104,8 @@ jobs: elif [ -n "$MANIFEST" ]; then echo "repositories=$(jq --compact-output "$MANIFEST_JQ" "$MANIFEST")" >> "$GITHUB_OUTPUT" else - # Default to this repo's own Docker Hub repository (lowercased owner/name) so a single-image caller - # carries the orchestration verbatim with no repo-specific value. + # Default to this repo's own Docker Hub repository, being the lowercased owner/name. + # A single-image caller then carries the orchestration verbatim with no repo-specific value. echo "repositories=[\"$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')\"]" >> "$GITHUB_OUTPUT" fi diff --git a/catalog/snippets/workflows/publish-plan-task.yml b/catalog/snippets/workflows/publish-plan-task.yml index b5997d2c..fa24cb4e 100644 --- a/catalog/snippets/workflows/publish-plan-task.yml +++ b/catalog/snippets/workflows/publish-plan-task.yml @@ -1,20 +1,24 @@ name: Publish plan task -# Single source of truth for the release-gate decision, reused by every publish-release.yml job so the policy -# lives here, not scattered across job `if:` conditions. A human PR merge never auto-publishes; a release is a -# deliberate dispatch, a bot (Dependabot/codegen) code-merge to main, or the Docker weekly schedule. +# Single source of truth for the release-gate decision, reused by every publish-release.yml job. +# The policy therefore lives here rather than scattered across job `if:` conditions. +# A human PR merge never auto-publishes. +# A release is a deliberate dispatch, a bot (Dependabot/codegen) code-merge to main, or the Docker weekly schedule. # # Outputs: -# publish - 'true' when this run should publish: a bot-authored push (the codegen App merges every bot PR, so -# its identity - or dependabot[bot] - is the gate), a schedule, or a workflow_dispatch of main/develop. +# publish - 'true' when this run should publish, meaning a bot-authored push, a schedule, or a workflow_dispatch of main/develop. +# The codegen App merges every bot PR, so its identity, or dependabot[bot], is the gate. # A human push (a merge/promotion to main) or a dispatch from any other branch is 'false'. -# stable - 'true' when the target branch is main (stable channel); main-only jobs gate on publish && stable. -# Both outputs are the strings 'true'/'false' - gate with == 'true'; a bare `if: ${{ needs.plan.outputs.publish }}` -# is always truthy (a non-empty string is truthy in an Actions expression). +# stable - 'true' when the target branch is main (the stable channel). +# Main-only jobs gate on publish && stable. # -# Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`; a -# Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. A case a given -# caller never triggers (e.g. schedule for a library) is simply inert for it - expected of a single-source task. +# Both outputs are the strings 'true'/'false', so gate with == 'true'. +# A bare `if: ${{ needs.plan.outputs.publish }}` is always truthy, since a non-empty string is truthy in an Actions expression. +# +# Shared across repo types: a library/package repo triggers only push + dispatch and uses `publish`. +# A Docker/wrapper repo also triggers the weekly schedule and gates main-only jobs on `stable`. +# A case a given caller never triggers (e.g. schedule for a library) is simply inert for it. +# That is expected of a single-source task. on: workflow_call: @@ -69,15 +73,17 @@ jobs: publish=true ;; push) - # A human merge never auto-publishes; only a bot merge to main does. The codegen App merges every - # Dependabot/codegen PR, so github.actor is its identity (dependabot[bot] allowed defensively). The - # ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. + # A human merge never auto-publishes, and only a bot merge to main does. + # The codegen App merges every Dependabot/codegen PR, so github.actor is its identity. + # The dependabot[bot] identity is allowed defensively alongside it. + # The ref==main guard keeps the task self-contained even if a caller's push trigger is not main-only. if [[ "$REF" == "main" ]] && { [[ "$ACTOR" == "ptr727-codegen[bot]" ]] || [[ "$ACTOR" == "dependabot[bot]" ]]; }; then publish=true elif [[ "$REF" == "main" ]]; then - # Fail loud: an unrecognized actor pushing to main is either a human commit (legitimately not - # publishing, but worth seeing) or a release bot under a new identity, which would otherwise - # stop publishing silently while a schedule keeps releasing - lost timeliness, no error. + # Fail loud, because an unrecognized actor pushing to main is one of two things. + # It is either a human commit, legitimately not publishing but worth seeing. + # Or it is a release bot under a new identity, which would otherwise stop publishing silently. + # A schedule would keep releasing meanwhile, costing timeliness with no error raised. echo "::warning::Push to main by unrecognized actor '$ACTOR'; not publishing. If this is a release bot under a new identity, update the allowlist in publish-plan-task.yml." fi ;; diff --git a/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml index f22d91bc..0d53cfee 100644 --- a/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml +++ b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml @@ -7,8 +7,9 @@ on: - cron: '0 4 * * *' concurrency: - # Workflow-only group (no `-${{ github.ref }}`): the task writes the fixed `codegen-main`/`codegen-develop` - # branches regardless of triggering ref, so a dispatch and the scheduled run must not race on them. + # Workflow-only group (no `-${{ github.ref }}`), since the task writes fixed branches. + # It writes `codegen-main` and `codegen-develop` regardless of the triggering ref. + # A dispatch and the scheduled run must therefore not race on them. group: ${{ github.workflow }} cancel-in-progress: true diff --git a/scripts/README.md b/scripts/README.md index b8bb89b0..b85df134 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -66,6 +66,10 @@ A string that spans lines carries its state onto the lines it covers, so a marke A comment sentence also has to start with a capital, which `comment-case` checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as `comment-wrap`, and a lowercase opening that is not a continuation reports as `comment-case`. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule. +**A comment body that is one token closing on a colon is a key or a heading rather than a sentence**, and neither rule applies to it. A `# ignore:` heading a commented-out block is disabled configuration, so the capitalization `comment-case` asks for corrupts the key a reader uncomments, and the rule damages the file it was pointed at. The token count carries the test, because a colon ending real prose always has words before it. Measured over this repo, the shape matches exactly two bodies, both of them a key or a heading, where the broader reading of a body with no whitespace at all covers 99 and sweeps in every reference URI and version marker with them. + +**A label opening a definition is exempt from `comment-case`, because the lowercase word is the name being defined.** A `# publish - 'true' when ...` documents an output named `publish`, and capitalizing it renames the output the workflow declares. This is the comment spelling of the `- **Label** - text` construct the dash rule already exempts, and it is the same reason a step marker is stripped before the sentence checks. The exemption costs a detection, stated rather than left to be found: a comment that genuinely opens a sentence on one word followed by a spaced dash is no longer read as a lowercase opening. It is scoped to a line that opens a definition and is never applied to one continuing a wrapped sentence, since a continuation whose first word takes a spaced dash is a parenthetical, which is the construction the dash rule exists to catch. Both live instances in this repo are continuations, so the corpus rather than the intuition chose the unit: the first design read any comment body and would have exempted both. + **A comment whose whole body is a URI is a reference rather than a sentence**, and neither rule applies to it. It cannot be capitalized or restructured without corrupting the address it exists to carry, so before the exemption every repo carrying a reference block inherited a finding no edit could answer. Consecutive reference lines are separate addresses rather than one sentence wrapping, which is why the exemption also stops the line below a URI from reading as its continuation. A URI inside a sentence is still prose, so the exemption requires the whole body to be the address and nothing else. `charset` and `dupword` are clean tree-wide and gate CI. `charset-unknown`, `semicolon`, `dash`, `comment-wrap`, and `comment-case` run as one warn-only CI step, so the backlog is visible without blocking and is corrected as each file is next edited. diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 5a04c6d3..37bb2eff 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -676,6 +676,22 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # `# 1. Deploy the hook.` is one sentence, and reading the marker's dot as a terminator made it two. # It is stripped before the sentence checks so both the run-on and the opening-case test see the prose. ENUM_PREFIX = re.compile(r'^\d+[.)]\s+') + +# A comment body that is one token closing on a colon is a key or a heading, not a sentence. +# `# ignore:` above a commented-out block is disabled configuration. +# Capitalizing it corrupts the key a reader uncomments, so the rule would damage the file. +# The token count carries the test, since a colon ending real prose always has words before it. +KEY_ONLY = re.compile(r'^\S+:$') + +# A label opening a definition names the thing being defined, so it is not the sentence's first word. +# `# publish - 'true' when ...` documents an output named `publish`. +# Capitalizing it renames the output the workflow declares. +# This is the comment spelling of the `- **Label** - text` construct LABEL_DASH exempts in Markdown. +# It is tested where a line opens a definition, never where one continues a wrapped sentence. +# A continuation whose first word is followed by a spaced dash is a parenthetical instead. +# That is the construction the dash rule exists to catch, so exempting it would hide the violation. +# Both live instances in the tree are continuations, which is what scoped this to the case branch. +COMMENT_LABEL = re.compile(r'^[A-Za-z_][\w.-]*\s+-\s+') CODE_FENCE = re.compile(r'^\s*(```|~~~)') # Both are correct English. `the the` is always a typo, so it is not here. @@ -929,7 +945,8 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ prev_body = '' prev_no = 0 for n, body, leading in comments: - if not body or NOT_PROSE.search(body) or BARE_URI.match(body.strip()): + if (not body or NOT_PROSE.search(body) or BARE_URI.match(body.strip()) + or KEY_ONLY.match(body)): prev_body = '' continue # An unpunctuated Markdown HTML comment is a structural marker, not commentary. @@ -951,7 +968,8 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ out.append((prev_no, 'comment-wrap', 'comment sentence wraps into the next line -> one sentence per line')) # A lowercase opening that is not a continuation is a sentence that failed to start. - elif leading and body[:1].islower(): + # A label opening a definition is exempt, since the lowercase word is the name being defined. + elif leading and body[:1].islower() and not COMMENT_LABEL.match(body): out.append((n, 'comment-case', 'comment sentence opens in lowercase -> capitalize, or restructure so it ' 'does not open on a lowercase name')) diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index ffbe2f32..9be5f1a3 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -610,6 +610,44 @@ def test_a_genuine_continuation_is_not_a_case_error(self) -> None: self.assertEqual(['comment-wrap'], self.flag('a.py', '# A sentence that keeps\n# going onto the next line.\n')) + def test_a_commented_out_key_is_not_a_sentence(self) -> None: + """`# ignore:` heading a disabled block is configuration, and capitalizing it breaks the key. + + The rule pointed at a codecov snippet whose commented-out `ignore:` block a repo uncomments, + so following the finding would have corrupted the file the exemption exists to protect. + """ + for text in ('# ignore:\n# - "Sandbox/**"\n', '# Outputs:\n'): + with self.subTest(text=text.splitlines()[0]): + self.assertEqual([], self.flag('a.yml', text)) + + def test_a_colon_ending_real_prose_is_still_judged(self) -> None: + """The exemption is one token wide, since prose closing on a colon has words before it.""" + self.assertEqual(['comment-case'], self.flag('a.yml', '# the outputs are these:\n')) + + def test_a_label_opening_a_definition_keeps_its_name(self) -> None: + """`publish` names the output being documented, so capitalizing it renames what ships.""" + for text in ("# publish - 'true' when this run should publish.\n", + "# stable - 'true' when the target branch is main.\n", + '# payload-file - create-or-update the ruleset by name.\n'): + with self.subTest(text=text.strip()[:30]): + self.assertEqual([], self.flag('a.yml', text)) + + def test_a_continuation_dash_is_not_read_as_a_label(self) -> None: + """A wrapped line whose first word takes a spaced dash is a parenthetical, not a definition. + + Two live instances in the tree have this shape, and exempting them would have hidden the + very construction the dash rule exists to catch. The label test is scoped to a line that + opens a definition, so a continuation never reaches it. + """ + self.assertEqual(['comment-wrap'], + self.flag('a.yml', '# It needs every other\n' + '# build - a target disabled on a smoke PR - does not.\n')) + + def test_a_label_does_not_hide_a_run_on(self) -> None: + """The exemption answers the opening word only, so the sentence checks still see the prose.""" + self.assertEqual(['comment-wrap'], + self.flag('a.yml', f'# publish - {self.RUN_ON}\n')) + def test_a_capitalized_opening_and_a_code_token_are_both_accepted(self) -> None: """A backticked identifier does not open in lowercase, so it needs no restructuring.""" for text in ('# The details element is allowed.\n', '# `ruff format` runs first.\n'):