diff --git a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index f19b5cf3..576c4538 100644 --- a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -61,8 +61,8 @@ and project-path inputs its targets need. Pick by where each artifact *goes*, not by language: -- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a build-executable - or build-nuget hook per output, each uploading `release-asset--`. This is where the +- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a dotnet-publish + hook or a build-nuget hook per output, each uploading `release-asset--`. This is where the .NET `dotnet publish` or `dotnet build` and package push lives. The hub default takes an explicit project path, and a project needing different build behavior replaces the hook. A data-only repo's own output (e.g. a symbol library) is not yet @@ -72,7 +72,7 @@ Pick by where each artifact *goes*, not by language: registry. NuGet pushes from inside the build-nuget hook (OIDC trusted publishing through `NuGet/login`, no stored API key) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: the build-pypi hook only builds and uploads the - `pypilibrary-build-` artifact, and the separate `publish-pypi` job in the caller's own + `pypi-build-` artifact, and the separate `publish-pypi` job in the caller's own `publish-release.yml` does the OIDC Trusted-Publishing upload (`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.yml`, hub-hosted like diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index d03c8439..828f0cd8 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -79a65cdb22c0eb39 +f0f6d839705b9d33 diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index f19b5cf3..576c4538 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -61,8 +61,8 @@ and project-path inputs its targets need. Pick by where each artifact *goes*, not by language: -- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a build-executable - or build-nuget hook per output, each uploading `release-asset--`. This is where the +- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a dotnet-publish + hook or a build-nuget hook per output, each uploading `release-asset--`. This is where the .NET `dotnet publish` or `dotnet build` and package push lives. The hub default takes an explicit project path, and a project needing different build behavior replaces the hook. A data-only repo's own output (e.g. a symbol library) is not yet @@ -72,7 +72,7 @@ Pick by where each artifact *goes*, not by language: registry. NuGet pushes from inside the build-nuget hook (OIDC trusted publishing through `NuGet/login`, no stored API key) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: the build-pypi hook only builds and uploads the - `pypilibrary-build-` artifact, and the separate `publish-pypi` job in the caller's own + `pypi-build-` artifact, and the separate `publish-pypi` job in the caller's own `publish-release.yml` does the OIDC Trusted-Publishing upload (`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.yml`, hub-hosted like diff --git a/.github/actions/dotnet-publish-default/action.yml b/.github/actions/dotnet-publish-default/action.yml index 99711505..a82842c0 100644 --- a/.github/actions/dotnet-publish-default/action.yml +++ b/.github/actions/dotnet-publish-default/action.yml @@ -101,7 +101,7 @@ runs: if: ${{ inputs.smoke != 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-asset-${{ inputs.branch }}-executable + name: release-asset-${{ inputs.branch }}-dotnet-publish path: ${{ steps.zip.outputs.asset-path }} # Consumed within the run by the github-release job, so minimize artifact storage. retention-days: 1 diff --git a/.github/actions/nuget-push-default/action.yml b/.github/actions/nuget-push-default/action.yml index 7258dfdb..97310c04 100644 --- a/.github/actions/nuget-push-default/action.yml +++ b/.github/actions/nuget-push-default/action.yml @@ -103,14 +103,14 @@ runs: - name: Zip output step if: ${{ inputs.smoke != 'true' }} shell: bash - run: 7z a -t7z "$RUNNER_TEMP/nugetlibrary.7z" "$RUNNER_TEMP"/publish/* + run: 7z a -t7z "$RUNNER_TEMP/nuget.7z" "$RUNNER_TEMP"/publish/* # GitHub-release asset, uploaded under the release-asset-- pattern the github-release job collects. - name: Upload release asset step if: ${{ inputs.smoke != 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-asset-${{ inputs.branch }}-nugetlibrary - path: ${{ runner.temp }}/nugetlibrary.7z + name: release-asset-${{ inputs.branch }}-nuget + path: ${{ runner.temp }}/nuget.7z # Consumed within the run by the github-release job, so minimize artifact storage. retention-days: 1 diff --git a/.github/actions/pypi-build-default/action.yml b/.github/actions/pypi-build-default/action.yml index 757a4fbe..ee8d6010 100644 --- a/.github/actions/pypi-build-default/action.yml +++ b/.github/actions/pypi-build-default/action.yml @@ -111,7 +111,7 @@ runs: if: ${{ inputs.smoke != 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: pypilibrary-build-${{ inputs.branch }} + name: pypi-build-${{ inputs.branch }} path: ${{ inputs.project-dir }}/dist/* # Consumed within the run by the caller stub's publish-pypi job, so minimize artifact storage. retention-days: 1 diff --git a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index f19b5cf3..576c4538 100644 --- a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -61,8 +61,8 @@ and project-path inputs its targets need. Pick by where each artifact *goes*, not by language: -- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a build-executable - or build-nuget hook per output, each uploading `release-asset--`. This is where the +- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a dotnet-publish + hook or a build-nuget hook per output, each uploading `release-asset--`. This is where the .NET `dotnet publish` or `dotnet build` and package push lives. The hub default takes an explicit project path, and a project needing different build behavior replaces the hook. A data-only repo's own output (e.g. a symbol library) is not yet @@ -72,7 +72,7 @@ Pick by where each artifact *goes*, not by language: registry. NuGet pushes from inside the build-nuget hook (OIDC trusted publishing through `NuGet/login`, no stored API key) *and* also uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is split: the build-pypi hook only builds and uploads the - `pypilibrary-build-` artifact, and the separate `publish-pypi` job in the caller's own + `pypi-build-` artifact, and the separate `publish-pypi` job in the caller's own `publish-release.yml` does the OIDC Trusted-Publishing upload (`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.yml`, hub-hosted like diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 26e9eb1b..f9053a43 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -58,7 +58,7 @@ on: required: false type: boolean default: true - enable_executable: + enable_dotnet_publish: required: false type: boolean default: true @@ -84,12 +84,12 @@ on: type: boolean default: false # Project paths forwarded to the hub default hooks. - executable_project: + dotnet_publish_project: required: false type: string default: '' # The release archive's name without .7z, derived from the project file's stem when empty. - executable_asset_name: + dotnet_publish_asset_name: required: false type: string default: '' @@ -158,9 +158,9 @@ jobs: exit 1 fi - build-executable: - name: Build executable job - if: ${{ inputs.enable_executable }} + dotnet-publish: + name: Publish .NET project job + if: ${{ inputs.enable_dotnet_publish }} needs: [get-version, validate-release] runs-on: ubuntu-latest steps: @@ -172,9 +172,9 @@ jobs: ref: ${{ needs.get-version.outputs.GitCommitId }} # A caller's own hook names its archive itself, so the asset name input reaches only the hub default. - - name: Run caller build-executable hook step - if: ${{ hashFiles('.github/actions/build-executable/action.yml') != '' }} - uses: ./.github/actions/build-executable + - name: Run caller dotnet-publish hook step + if: ${{ hashFiles('.github/actions/dotnet-publish/action.yml') != '' }} + uses: ./.github/actions/dotnet-publish with: branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} @@ -182,10 +182,10 @@ jobs: assembly-version: ${{ needs.get-version.outputs.AssemblyVersion }} assembly-file-version: ${{ needs.get-version.outputs.AssemblyFileVersion }} assembly-informational-version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} - project-file: ${{ inputs.executable_project }} + project-file: ${{ inputs.dotnet_publish_project }} - - name: Run hub build-executable default step - if: ${{ hashFiles('.github/actions/build-executable/action.yml') == '' }} + - name: Run hub dotnet-publish default step + if: ${{ hashFiles('.github/actions/dotnet-publish/action.yml') == '' }} uses: $/.github/actions/dotnet-publish-default with: branch: ${{ inputs.branch }} @@ -194,8 +194,8 @@ jobs: assembly-version: ${{ needs.get-version.outputs.AssemblyVersion }} assembly-file-version: ${{ needs.get-version.outputs.AssemblyFileVersion }} assembly-informational-version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} - project-file: ${{ inputs.executable_project }} - asset-name: ${{ inputs.executable_asset_name }} + project-file: ${{ inputs.dotnet_publish_project }} + asset-name: ${{ inputs.dotnet_publish_asset_name }} build-nuget: name: Build NuGet library job @@ -276,7 +276,7 @@ jobs: build-docker: name: Build Docker image job - needs: [get-version, validate-release, build-executable, build-nuget, build-pypi] + needs: [get-version, validate-release, dotnet-publish, build-nuget, build-pypi] if: ${{ inputs.enable_docker && !failure() && !cancelled() }} uses: $/.github/workflows/build-docker-task.yml secrets: @@ -304,7 +304,7 @@ jobs: # The explicit functions replace that implicit success() instead, tolerating a skipped need while still failing closed on a real failure. if: ${{ inputs.github && !inputs.smoke && !failure() && !cancelled() }} runs-on: ubuntu-latest - needs: [get-version, validate-release, build-executable, build-nuget, build-pypi, build-docker] + needs: [get-version, validate-release, dotnet-publish, build-nuget, build-pypi, build-docker] # The release upload and the artifact-delete cleanup both write with GITHUB_TOKEN, and the caller grants contents: write and actions: write when it sets github: true on a non-smoke run. # A caller publishing only to a registry, or smoke building, leaves this job disabled and grants neither. # No job-level permissions: block here, for the reason build-nuget gives: a block is validated against the caller's grant before if: runs, and a smoke caller holds a read-only token. diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 52f78796..7ef981c6 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -68,6 +68,6 @@ jobs: enable_docker: false enable_nuget: false enable_pypi: false - enable_executable: false + enable_dotnet_publish: false # This repo is source-only, so the release is just the tag + source zip + README + LICENSE. expect_release_assets: false diff --git a/GOVERNANCE.md b/GOVERNANCE.md index fa671c5c..10997a25 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -207,7 +207,7 @@ This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this sectio - **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it (the `github-release` job deletes `release-asset--*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypi-build-` after publishing). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. - **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`:buildcache-` for each image, plus the base image's own tag and inline cache). It does not fall back to `type=gha` for the extra images. - **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly, because without it GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (which may differ from the exact commit NBGV versioned) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). diff --git a/WORKFLOW.md b/WORKFLOW.md index a664bea2..c891a955 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -78,8 +78,8 @@ A target contributes a file to the GitHub release by uploading a workflow artifa ```mermaid flowchart LR - leafa[leaf: target A] -->|release-asset-branch-A| store[(run artifacts)] - leafb[leaf: target B] -->|release-asset-branch-B| store + dotnet[dotnet-publish] -->|release-asset--dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset--nuget| store store -->|pattern + merge-multiple| rel["github-release job (D6)"] reg[registry leaf: nuget / pypi / docker] -->|push, no asset| registries[(registries)] ``` @@ -98,7 +98,7 @@ When a workflow's inputs carry a cross-input or input-versus-derived-state invar ### Resource Lifecycle -Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed, and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. ### Fast PR Feedback @@ -172,12 +172,12 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved ref. *Prevents: the tag landing on the default branch instead of the built tree.* - **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, and PyPI does the same under `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.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 .NET publish 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, and a dead config watcher read as a routing fault.* ### D5 - Resource Cleanup -- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* - **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too. Where the consumer always runs when its job runs (the PyPI publish step), the delete always runs, so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* - **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`. @@ -234,7 +234,7 @@ Read the workflow files plus `version.json` and assert the structural fact behin **Per-type addenda (apply only the ones present):** -- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix. The per-runtime outputs (`publish--`) are aggregated by `pattern:` + `merge-multiple:` into one `release-asset--` and the aggregation job is gated `!smoke`. The per-runtime intermediates rely on the retention backstop (no explicit delete is required for an in-run intermediate). +- **.NET publish:** the smoke runtime set is a strict non-empty subset of the full runtime set. The selected set runs sequentially inside one composite-action job. A non-smoke run uploads one `release-asset--dotnet-publish` artifact, while a smoke run skips the archive and upload steps. - **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 job is gated main-only, both by the caller's branch input and inside the hub-hosted `publish-docker-readme-task.yml` itself. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. @@ -281,11 +281,11 @@ The workflow is **operational** iff every *applicable* 5A item passes and every Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. -- **Console / executable application.** Target produces `release-asset--executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish--` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes. The per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest", and the workflow does not set it). -- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset--nugetlibrary`. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the asset zip also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. -- **PyPI library.** The leaf builds + uploads `pypilibrary-build-`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. +- **.NET publish.** The target runs a sequential `dotnet publish` runtime loop inside one composite-action job. Configuration is Release on `main` and Debug otherwise. A non-smoke run builds the full runtime set, zips the combined output, and uploads `release-asset--dotnet-publish`. The archive is named from the project file stem unless `dotnet_publish_asset_name` overrides it. A smoke run builds a two-runtime subset and skips the zip and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. S7 attaches the 7z from a non-smoke run. The non-default leg sets `prerelease=true`, and the default leg sets `prerelease=false`. GitHub marks the stable default release "Latest" automatically. +- **NuGet.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset--nuget`. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the asset zip also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. +- **PyPI.** The leaf builds and uploads `pypi-build-`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. - **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 job (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) runs **only** when the default branch publishes, whether called directly or reached through the hub-hosted `publish-docker-readme-task.yml`; 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 the readme. 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. +- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke`, mirroring the NuGet 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, and .NET publish 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. A source-only repo MAY instead reach the hub-hosted `build-release-task.yml` with every `enable_*` input false and `expect_release_assets: false`, which reaches the identical tag-plus-source-zip outcome through the reusable task rather than an inlined `action-gh-release` step. This document states outcomes, not implementation (section 1), so either shape satisfies D4.3 and the scenarios above. The hub's own `publish-release.yml` takes this path so its own release exercises the reusable task. - **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, while 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 the hub-hosted `deploy-site-task.yml`, binding the same `environment:` on the caller's own job so the one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, resolves from the GitHub Environment store and can be mapped explicitly rather than through `secrets: inherit`, which a cross-repository reusable workflow cannot use. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task 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), 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), runs a required deploy hook that builds the tree with whatever generator and precompression the site owns, 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, then runs the same hook again to prune old releases and to check 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 the direct-commit path) plus `pull_request` to `branches: [ main, develop ]` (enforced on `main` as the promotion gate, reported but not required on `develop`) 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`). **The PR trigger names both branches, and naming `main` alone is a defect.** A set that omits `develop` matches nothing when a PR opens against `develop`, so the validation job never starts, the aggregator never reports, and the PR shows a clean mergeable state with an empty check list, which is the exact output D1.2 forbids. Naming both costs a duplicate run, since a PR-merged change validates once on the PR and again on the push when the merge lands, whatever merge method the repo allows (the operational `develop` ruleset prescribes none). The concurrency group is keyed on the workflow name plus `${{ github.ref }}` (Section 2), and a `pull_request` run's ref is `refs/pull//merge` where the push run's is `refs/heads/develop`, so the two occupy different groups and neither cancels the other. Pay it. On a lint-only gate that is a couple of runner-minutes, where the alternative, a condition that suppresses the push run, has to tell a merge commit from a direct commit and reintroduces exactly the reasoning the trigger set removes. Applicable scenarios: S1 (validation) on every PR, the promotion PR and a PR into `develop` alike, 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/configs/docker-hub-readme.md b/catalog/snippets/configs/docker-hub-readme.md index a8c40773..42ab829a 100644 --- a/catalog/snippets/configs/docker-hub-readme.md +++ b/catalog/snippets/configs/docker-hub-readme.md @@ -1,6 +1,6 @@ # ProjectTemplate -Container image for the ProjectTemplate Console app. +Container image for the ProjectTemplate .NET application. ## Image Tags diff --git a/catalog/snippets/devcontainer/python/devcontainer.json b/catalog/snippets/devcontainer/python/devcontainer.json index 878d13fd..25c0d1fa 100644 --- a/catalog/snippets/devcontainer/python/devcontainer.json +++ b/catalog/snippets/devcontainer/python/devcontainer.json @@ -36,7 +36,7 @@ // Idempotent on Linux and WSL2. "onCreateCommand": "sudo install -d -m 700 -o vscode -g vscode /home/vscode/.ssh", - // Install pinned uv and pre-warm the PyPiLibrary venv. + // Install pinned uv and pre-warm the project environment. // No git hooks are installed by default, per README "Optional: enable git hooks locally". "postCreateCommand": ".devcontainer/python/post-create.sh", diff --git a/catalog/snippets/devcontainer/python/post-create.sh b/catalog/snippets/devcontainer/python/post-create.sh index 2f1a34d6..e1c82850 100755 --- a/catalog/snippets/devcontainer/python/post-create.sh +++ b/catalog/snippets/devcontainer/python/post-create.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -Eeuo pipefail +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repository_dir=$(cd -- "$script_dir/../.." && pwd) + # 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. @@ -39,8 +42,6 @@ if [[ "$installed_uv_version" != "$UV_VERSION" ]]; then export PATH="$HOME/.local/bin:$PATH" fi -# 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) +if [[ -f "$repository_dir/pyproject.toml" ]]; then + uv sync --project "$repository_dir" fi diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index 0109764b..bbeec4db 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -1,14 +1,13 @@ # 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/`, plus the hub-hosted reusable tasks a downstream repo reaches rather than carries (`merge-bot-task`, `get-version-task`, `publish-plan-task`, `build-release-task`, `build-docker-task`, `publish-docker-readme-task`, `check-upstream-version-task`, `deploy-site-task`, `run-codegen-pull-request-task`, per [`docs/reusable-workflows.md`][reusable-workflows]). Each row below names the canonical implementation of one or more `WORKFLOW.md` guarantees, whether the file lives in this directory or is hub-hosted and reached by pin. The audit asserts a downstream repo's own Actions satisfy those guarantees, not that they match these bytes. The table does not yet carry a row for every name in the parenthetical above: `build-release-task.yml`, `build-docker-task.yml`, `publish-docker-readme-task.yml`, `check-upstream-version-task.yml`, `deploy-site-task.yml`, and `run-codegen-pull-request-task.yml` are hub-hosted with no row here, for the reason the next paragraph gives. The release-chain hooks (`build-executable`, `build-nuget`, `build-pypi`, `docker-prepare`, `docker-build-base`) are composite actions rather than snippets here, since a hook is per-repo content and this catalog carries only what a repo copies whole. +The reusable build and publish workflow tasks serve code-shipping repositories. They are **inert reference here** because this repository is source-only. Its `.github/workflows/` directory keeps only `test-pull-request`, `publish-release`, `validate-task`, and `merge-bot-pull-request`. Downstream repositories reach the hub-hosted reusable tasks listed in [`docs/reusable-workflows.md`][reusable-workflows]. Each row below names the canonical implementation of one or more `WORKFLOW.md` guarantees. The implementation can live here or be reached from the hub by pin. The audit checks that downstream Actions satisfy those guarantees, not that they match these bytes. The table does not yet carry every hub-hosted task because the next paragraph explains when caller snippets ship. Release-chain hooks are composite actions because each hook is repository-owned content. They are `dotnet-publish`, `build-nuget`, `build-pypi`, `docker-prepare`, and `docker-build-base`. -A caller stub for a hub-hosted task carries no snippet of its own until the task ships in a release: its `uses:` line would pin a commit no release carries yet, which the pin gate rejects. `test-pull-request.yml`, `publish-release.yml`, and `run-periodic-codegen-pull-request.yml` gained theirs once `2.0.352` released `validate-task.yml`, `build-release-task.yml`, and `run-codegen-pull-request-task.yml`. [`docs/reusable-workflows.md`][reusable-workflows] "Adopting the Gates", "Adopting the Release Chain", and "Adopting the Type-Specific Tasks" carry the remaining stub shapes (the release-with-smoke variant, `deploy-site.yml`, `publish-docker-readme-task.yml`, and `check-upstream-version-task.yml`) as reference until each has a snippet of its own. `get-version-task.yml` and `publish-plan-task.yml` below carry no snippet at all, for a different reason: each is called as a job inside a larger stub rather than reached by its own top-level caller. +A caller stub for a hub-hosted task carries no snippet of its own until the task ships in a release: its `uses:` line would pin a commit no release carries yet, which the pin gate rejects. `test-pull-request.yml` and `run-periodic-codegen-pull-request.yml` gained theirs once `2.0.352` released `validate-task.yml` and `run-codegen-pull-request-task.yml`. The release caller snippet is withheld until a release carries the renamed .NET publish interface. [`docs/reusable-workflows.md`][reusable-workflows] "Adopting the Gates", "Adopting the Release Chain", and "Adopting the Type-Specific Tasks" carry the remaining stub shapes as reference until each has a snippet of its own. `get-version-task.yml` and `publish-plan-task.yml` below carry no snippet at all, for a different reason: each is called as a job inside a larger stub rather than reached by its own top-level caller. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | | `merge-bot-pull-request.yml` | Caller stub for the hub-hosted merge-bot task, pinned to a hub release, the shape every repo carries | D8.1, D8.3, D8.4 | | `test-pull-request.yml` | Caller stub for the hub-hosted `validate-task.yml`, the no-build operational trigger shape, pinned to a hub release | D1.2, D1.5 | -| `publish-release.yml` | Caller stub reaching the hub-hosted `publish-plan-task.yml`, `validate-task.yml`, and `build-release-task.yml` by pin, the release-repo shape whose merges publish | D4.1 | | `run-periodic-codegen-pull-request.yml` | Caller stub for the hub-hosted `run-codegen-pull-request-task.yml`, pinned to a hub release, the same per-repo shape a codegen repo carries today | D8.2 | | `.github/workflows/get-version-task.yml` | Hub-hosted and reached by pin from a leaf or publisher rather than carried, with no caller-stub snippet since it is called as a job inside a larger stub, per [`docs/reusable-workflows.md`][reusable-workflows] | D3 | | `.github/workflows/publish-plan-task.yml` | Hub-hosted and reached by pin from every publish-release job rather than carried, with no caller-stub snippet since it is called as a job inside a larger stub, per [`docs/reusable-workflows.md`][reusable-workflows] | D4 | diff --git a/catalog/snippets/workflows/publish-release.yml b/catalog/snippets/workflows/publish-release.yml deleted file mode 100644 index c38221bb..00000000 --- a/catalog/snippets/workflows/publish-release.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Publish project release action - -on: - push: - branches: [main] - paths: - - 'Widget/**' - - 'version.json' - - 'Directory.Build.props' - - 'Directory.Packages.props' - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -# GITHUB_TOKEN gets no scope by default, and each job below grants only what its hub task writes with. -permissions: {} - -jobs: - - # Single source of the release-gate decision (publish or not, stable or not), reused by every job below. - plan: - name: Plan release job - uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 - with: - event_name: ${{ github.event_name }} - actor: ${{ github.actor }} - ref_name: ${{ github.ref_name }} - - # The same reusable gate the PR runs, on the branch tip, running only when a publish will happen. - validate: - name: Validate job - needs: [plan] - if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 - permissions: - contents: read - secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - # Build, version, validate, push, and release the triggering branch. - # Grants the write scopes the hub task needs (it declares none of its own except where a job genuinely writes, per its own header). - publish: - name: Publish project release job - needs: [plan, validate] - if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 - secrets: - NUGET_USERNAME: ${{ secrets.NUGET_USERNAME }} - permissions: - contents: write - id-token: write - actions: write - with: - ref: ${{ github.sha }} - branch: ${{ github.ref_name }} - smoke: false - github: true - nuget: true - enable_docker: false - enable_pypi: false - enable_executable: false - nuget_project: ./Widget/Widget.csproj diff --git a/cspell.json b/cspell.json index 7c639fa5..719199bf 100644 --- a/cspell.json +++ b/cspell.json @@ -86,7 +86,6 @@ "noninteractive", "noqa", "nuget", - "nugetlibrary", "onCreateCommand", "opencontainers", "Optix", @@ -101,7 +100,6 @@ "pydantic", "pypa", "pypi", - "pypilibrary", "pyproject", "pyright", "pyrightconfig", diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 606aad81..d0a22478 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -85,7 +85,7 @@ The target set. A row exists once its hub task ships, and until then the row is | `merge-bot-task.yml` | none, extra bot rules are a `with:` input | not applicable | | `validate-task.yml` | `validate` (a repo's own domain checks, beyond the fleet doc-lint block and the generic unit-test job) | no-op | | `get-version-task.yml`, `publish-plan-task.yml` | none | not applicable | -| `build-release-task.yml` | `build-executable`, `build-nuget`, `build-pypi` | `dotnet-publish-default`, `nuget-push-default`, `pypi-build-default` | +| `build-release-task.yml` | `dotnet-publish`, `build-nuget`, `build-pypi` | `dotnet-publish-default`, `nuget-push-default`, `pypi-build-default` | | `build-docker-task.yml` | `docker-prepare` (extra tags, build-args, matrix), `docker-build-base` | vanilla single-target from `image`, base build required when `build-base` | | `publish-docker-readme-task.yml` | `docker-readme-transform` | publish `Docker/README.md` or `README.md` as-is | | `check-upstream-version-task.yml` | `resolve-upstream` | none, required | @@ -121,7 +121,7 @@ A stage carries three kinds of item, plus a proof item where a claim needs a liv Adoptable since `2.0.338`. Each repo replaces the whole of its `.github/workflows/merge-bot-pull-request.yml` with the stub in [Adopting the Merge-Bot][adopting-the-merge-bot], on its own feature branch, and the audit's `missing required job 'merge-bot'` finding on that file is the work list. The pilot goes first and records what the hub cannot prove, cross-repository resolution of the pin, the `rules` input where the repo has a tracker, and the first Dependabot bump of the pin, as proof items here. -- [x] PhotoCleaner (pilot, chosen as a release-model repo with Dependabot, C#, executable and Docker targets and a fresh resync, so what it shows is the mechanism): adopted on `develop` in ptr727/PhotoCleaner#53 at `a3158ce` and promoted to `main`, its ground-truth branch, in ptr727/PhotoCleaner#54 at `4efcae8`, both on 2026-08-15, where `python3 spec/audit.py PhotoCleaner` reports no `interface` finding on the file. The live proofs it owes, cross-repository resolution of the pin with a Dependabot PR to `develop` merged with `--squash` through the callee, and Dependabot bumping the pin, are the two proof items directly below, ticked with their evidence when they happen. +- [x] PhotoCleaner (pilot, chosen as a release-model repo with Dependabot, .NET publish and Docker targets and a fresh resync, so what it shows is the mechanism): adopted on `develop` in ptr727/PhotoCleaner#53 at `a3158ce` and promoted to `main`, its ground-truth branch, in ptr727/PhotoCleaner#54 at `4efcae8`, both on 2026-08-15, where `python3 spec/audit.py PhotoCleaner` reports no `interface` finding on the file. The live proofs it owes, cross-repository resolution of the pin with a Dependabot PR to `develop` merged with `--squash` through the callee, and Dependabot bumping the pin, are the two proof items directly below, ticked with their evidence when they happen. - [ ] Proof: the first `pull_request_target` run on PhotoCleaner `develop` after `a3158ce` resolves the owner-scoped `uses:` and merges the Dependabot PR that opened it. Tick with the run URL. - [ ] Proof: Dependabot opens a `Bump ptr727/ProjectTemplate` PR on PhotoCleaner after the next hub release. Tick with the PR. - [ ] HomeAutomation-Config (operational model, the direct-to-develop path) @@ -180,22 +180,22 @@ Hub: `get-version-task.yml` and `publish-plan-task.yml` hosted, and the downstre ### Stage 4: The Release Chain and the Docker Core -Hub: `build-release-task.yml` with `build-executable`, `build-nuget`, `build-pypi` hooks, and `build-docker-task.yml` per [The Docker Family][the-docker-family]. The three no-asset release shapes collapse into `expect_release_assets`. No `publish-release-task.yml` ships: a caller stub's trigger policy and its `plan`, `validate`, `publish`, and `publish-pypi` jobs each reach one hub task directly, and none of that wiring is generic enough across the fleet's five trigger shapes (dispatch-only Docker schedule, push-gated NuGet/PyPI, KiCad's branch-matrix dispatch) to host as a further reusable workflow without becoming another set of caller-owned inputs. The `release-assets` hook (extra files beyond a target's own) stays unshipped too: no cataloged repo needs it today, and adding an unexercised hook is deferred until one does. +Hub: `build-release-task.yml` provides the `dotnet-publish`, `build-nuget`, and `build-pypi` hooks. `build-docker-task.yml` follows [The Docker Family][the-docker-family]. The three no-asset release shapes collapse into `expect_release_assets`. No `publish-release-task.yml` ships. A caller stub's `plan`, `validate`, `publish`, and `publish-pypi` jobs each reach one hub task directly. That wiring varies across the fleet's five trigger shapes, so another reusable workflow would become caller-owned inputs. The `release-assets` hook for extra files stays unshipped because no cataloged repo needs it. `build-release-task.yml` reaches `get-version-task.yml` and `build-docker-task.yml` through `$/`, so both sibling tasks resolve at the same hub commit the downstream caller pins. It keeps `validate-release` inline because that gate belongs to the release orchestrator. `build-docker-task.yml` also ships as a task in its own right for a caller that wants only the Docker leg. The `dotnet-publish-default`, `nuget-push-default`, and `pypi-build-default` actions require explicit project paths. Each default action validates its required inputs when selected, while caller-provided hooks remain free to use different inputs. - [x] Hub pull request on `develop` in #762. - [x] Promoted to `main` in #774 (`0b07a59d`) and released as `2.0.352`, the first tag carrying `build-release-task.yml` and `build-docker-task.yml`. The first release attempt, on `82fecef`, ended in `startup_failure` in [run-startup-failure][run-startup-failure] because `build-nuget` and `github-release` declared job-level permissions. #772 fixed it before #774 promoted. -- [x] PhotoCleaner (pilot, vanilla Docker plus executable): ptr727/PhotoCleaner#55 on `develop` (`c80cb29`), promoted in ptr727/PhotoCleaner#56 (`fa91db0`), five carried task files deleted, every value mapped to a task input (`executable_project`, `docker_image`), no repo hook needed. Its first publish through the task, [pilot publish run][pilot-publish-run], released `1.1.11` with the executable asset attached and the Docker image pushed. That run exposed one regression the pilot exists to find: the hub executable default named the archive `Console.7z` where the repo's own leaf named it `PhotoCleaner.7z`, fixed in the pull request that ticks this item by deriving the name from the project file (an `executable_asset_name` input overrides it), so the next PhotoCleaner release is the proof of the fix. +- [x] PhotoCleaner (pilot, vanilla Docker plus .NET publish): ptr727/PhotoCleaner#55 on `develop` (`c80cb29`), promoted in ptr727/PhotoCleaner#56 (`fa91db0`). The migration deleted five carried task files. It mapped every value to a task input (`dotnet_publish_project`, `docker_image`) and needed no repo hook. The [pilot publish run][pilot-publish-run] released `1.1.11` with the .NET publish asset and Docker image. That run exposed an archive naming regression between the hub default and the repo leaf. The fix derives the name from the project file, with `dotnet_publish_asset_name` as an override. The next PhotoCleaner release proves the fix. - [ ] PlexCleaner (second, the same shape) - [ ] VSCode-Server-DotNetCore (vanilla Docker only) - [ ] ESPHome-NonRoot (`docker-prepare` hook for the upstream pin) - [ ] NxWitness (matrix hook and `build-base`) - [ ] The NuGet, PyPI and remaining release repos, one checkbox each added when the pilots close. -- [x] A smoke build through `build-release-task.yml` observed on the PhotoCleaner pilot's pull request, [pilot smoke run][pilot-smoke-run]: get-version, validate-release, `build-executable`, `docker-prepare` and `build-docker` all through hub defaults, nuget, pypi and the base build skipped. +- [x] The [pilot smoke run][pilot-smoke-run] exercised `build-release-task.yml` on PhotoCleaner's pull request. Hub defaults ran get-version, validate-release, `dotnet-publish`, `docker-prepare`, and `build-docker`. NuGet, PyPI, and the base build skipped. - [x] Cross-repository `$/` resolution observed on PhotoCleaner pull request #58 in [self-reference smoke run][self-reference-smoke-run]: nested get-version and Docker tasks, the .NET publish default, and the Docker prepare default all resolved from the pinned hub feature commit and passed. - [x] A real publish through `build-release-task.yml` observed on the PhotoCleaner pilot, [pilot publish run][pilot-publish-run]: release `1.1.11` on `fa91db0` with `Publish GitHub release job` and `Build Docker image job` both succeeding. -- [ ] Proof: the next PhotoCleaner release names its executable asset `PhotoCleaner.7z`, which the asset-name fix in this repository derives from the project file. Tick with the release. +- [ ] Proof: the next PhotoCleaner release names its .NET publish asset `PhotoCleaner.7z`, which the asset-name fix in this repository derives from the project file. Tick with the release. - [ ] `reports/workflow-reuse.md` regenerated with `build-release-task.yml` and `build-docker-task.yml` at 0 copies (hub-only files) and `publish-release.yml` showing callers equal to copies. ### Stage 5: The Type-Specific Tasks @@ -422,7 +422,7 @@ A repo whose publisher needs the release-gate decision reaches `publish-plan-tas ## Adopting the Release Chain -A downstream repo replaces the whole of its `.github/workflows/build-release-task.yml`, and every per-target leaf task it carries (`build-executable-task.yml`, `build-nugetlibrary-task.yml`, `build-pypilibrary-task.yml`, `build-docker-task.yml`), with a caller stub in its own `publish-release.yml` reaching the hub tasks by pin. `test-pull-request.yml`'s smoke job calls `build-release-task.yml` the same way, with `smoke: true` and the paths-filter's `enable_*` outputs. The full shape below is now `catalog/snippets/workflows/publish-release.yml`, pinned to `0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352`, the release that first carries `build-release-task.yml` ([Pinning][pinning]). The task declares no job-level `permissions:` of its own, because a called job's block is validated against the caller's grant before its `if:` runs and would fail a caller that does not grant it at startup. The caller therefore grants only what its enabled paths write with: `contents: write` and `actions: write` when it sets `github: true` on a non-smoke run (the release upload and the artifact cleanup), `id-token: write` when it sets `nuget: true` (a real push through `NuGet/login`), and nothing beyond `contents: read` on a build-only or smoke run, where a Dependabot pull request holds a read-only token. +A downstream repo replaces its carried release orchestrator and per-target leaf tasks with a caller stub in its own `publish-release.yml` reaching the hub tasks by pin. `test-pull-request.yml`'s smoke job calls `build-release-task.yml` the same way, with `smoke: true` and the paths-filter's `enable_*` outputs. The renamed .NET publish interface is not yet in a hub release, so the full shape below keeps a pin placeholder and the catalog snippet is withheld until that release exists ([Pinning][pinning]). The task declares no job-level `permissions:` of its own, because a called job's block is validated against the caller's grant before its `if:` runs and would fail a caller that does not grant it at startup. The caller therefore grants only what its enabled paths write with: `contents: write` and `actions: write` when it sets `github: true` on a non-smoke run (the release upload and the artifact cleanup), `id-token: write` when it sets `nuget: true` (a real push through `NuGet/login`), and nothing beyond `contents: read` on a build-only or smoke run, where a Dependabot pull request holds a read-only token. The stub keeps its own trigger policy exactly as today: `workflow_dispatch` plus a main-only weekly `schedule` for a Docker repo, or `workflow_dispatch` plus a paths-filtered `push` to `main` for a NuGet or PyPI repo whose merges should auto-publish. What moves to the hub is the release-gate decision, the build/version/publish job graph, and the Docker core, never the trigger. This is the full shape, a NuGet-library repo whose merges publish: @@ -451,7 +451,7 @@ jobs: # Single source of the release-gate decision (publish or not, stable or not), reused by every job below. plan: name: Plan release job - uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@ # with: event_name: ${{ github.event_name }} actor: ${{ github.actor }} @@ -462,7 +462,7 @@ jobs: name: Validate job needs: [plan] if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # permissions: contents: read secrets: @@ -474,7 +474,7 @@ jobs: name: Publish project release job needs: [plan, validate] if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@ # secrets: NUGET_USERNAME: ${{ secrets.NUGET_USERNAME }} permissions: @@ -489,7 +489,7 @@ jobs: nuget: true enable_docker: false enable_pypi: false - enable_executable: false + enable_dotnet_publish: false nuget_project: ./Widget/Widget.csproj ``` @@ -510,7 +510,7 @@ A Docker repo's stub adds `schedule: - cron: '0 2 * * MON'` to the trigger block - name: Download build artifacts step uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: pypilibrary-build-${{ github.ref_name }} + name: pypi-build-${{ github.ref_name }} path: ./dist - name: Publish to PyPI step uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/registry/repos.json b/registry/repos.json index 1e3667e1..4927e659 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -103,7 +103,7 @@ "name": "PlexCleaner", "url": "https://github.com/ptr727/PlexCleaner", "status": "cataloged", - "types": ["csharp", "console", "docker", "python"], + "types": ["csharp", "dotnet-publish", "docker", "python"], "groundTruthBranch": "main", "hasDevelop": true, "publish": [ @@ -114,7 +114,7 @@ "consumerModel": "pull", "releaseTrigger": "two-phase", "configLayout": { "rulesetsDir": "repo-config", "pythonConfig": null }, - "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline.", "First csharp+python repo: a C# console app at the root plus a stdlib-only Python tooling subtree (RegressionTests/, uvx scripts profile - no uv.lock, pyproject carries only ruff+mypy config; PlexCleaner#855). python.uvlock.pinned and python.coverage.codecov are N/A for that subtree (no uv project, no tests); codecov.yml stays required for the C# side. Reference for the csharp+python shape (issue #339)."] + "driftNotes": ["Carries ARCHITECTURE.md and codecov.yml beyond the baseline.", "First csharp+python repo: a .NET application at the root plus a stdlib-only Python tooling subtree (RegressionTests/, uvx scripts profile - no uv.lock, pyproject carries only ruff+mypy config; PlexCleaner#855). python.uvlock.pinned and python.coverage.codecov are N/A for that subtree (no uv project, no tests); codecov.yml stays required for the C# side. Reference for the csharp+python shape (issue #339)."] }, { "name": "ESPHome-NonRoot", @@ -244,13 +244,13 @@ "name": "PhotoCleaner", "url": "https://github.com/ptr727/PhotoCleaner", "status": "cataloged", - "types": ["csharp", "console", "docker"], + "types": ["csharp", "dotnet-publish", "docker"], "hasDevelop": true, "publish": [{ "target": "docker", "mechanism": "static-secret" }, { "target": "github-release", "mechanism": "none" }], "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN", "CODECOV_TOKEN"], "consumerModel": "pull", "releaseTrigger": "two-phase", - "driftNotes": ["Multi-arch Docker (linux/amd64,linux/arm64 on main) pushed to Docker Hub via the static DOCKER_HUB_USERNAME/DOCKER_HUB_ACCESS_TOKEN secret, plus a github-release attaching the multi-arch executables as a 7z. Release is two-phase (workflow_dispatch + weekly Mon 02:00 schedule).", "Private for now by maintainer decision, going public once the conformance work settles, since public serves prospective users and lowers the GitHub bill. Until that flip the declared github-release channel is not consumer-pullable and shields.io cannot read the repo, so the GitHub-sourced shields render broken."] + "driftNotes": ["Multi-arch Docker (linux/amd64,linux/arm64 on main) pushed to Docker Hub via the static DOCKER_HUB_USERNAME/DOCKER_HUB_ACCESS_TOKEN secret, plus a GitHub release attaching the multi-runtime .NET publish output as a 7z. Release is two-phase (workflow_dispatch + weekly Mon 02:00 schedule).", "Private for now by maintainer decision, going public once the conformance work settles, since public serves prospective users and lowers the GitHub bill. Until that flip the declared github-release channel is not consumer-pullable and shields.io cannot read the repo, so the GitHub-sourced shields render broken."] }, { "name": "MediaTools", @@ -269,7 +269,7 @@ "name": "AudioCleaner", "url": "https://github.com/ptr727/AudioCleaner", "status": "cataloged", - "types": ["csharp", "console"], + "types": ["csharp", "dotnet-publish"], "groundTruthBranch": "main", "hasDevelop": true, "publish": [], diff --git a/spec/audit.py b/spec/audit.py index 3beedeb0..ed1ddacc 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2714,10 +2714,10 @@ def _selftest(): ) # The needs-mask case, where a verbatim job region whose `needs:` list is pruned to the repo's vendored targets must not count as drift, since the list is owned, while a structural change to the job's steps must. - needs_full = " github-release:\n needs: [get-version, validate-release, build-nugetlibrary, build-executable]\n runs-on: x\n steps: []\n" - needs_pruned = " github-release:\n needs: [get-version, validate-release, build-executable]\n runs-on: x\n steps: []\n" - needs_block = " github-release:\n needs:\n - get-version\n - build-executable\n runs-on: x\n steps: []\n" - needs_scalar = " github-release:\n needs: build-executable\n runs-on: x\n steps: []\n" + needs_full = " github-release:\n needs: [get-version, validate-release, build-nuget, dotnet-publish]\n runs-on: x\n steps: []\n" + needs_pruned = " github-release:\n needs: [get-version, validate-release, dotnet-publish]\n runs-on: x\n steps: []\n" + needs_block = " github-release:\n needs:\n - get-version\n - dotnet-publish\n runs-on: x\n steps: []\n" + needs_scalar = " github-release:\n needs: dotnet-publish\n runs-on: x\n steps: []\n" needs_forked = " github-release:\n needs: [get-version, validate-release]\n runs-on: x\n steps:\n - run: fork\n" if ( len( diff --git a/spec/project-types.json b/spec/project-types.json index 2c783d64..dc494d5f 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -15,7 +15,7 @@ ] }, "nuget": { - "detect": ["build-nugetlibrary-task.yml", "dotnet nuget push"], + "detect": [".github/actions/build-nuget/action.yml", "dotnet nuget push", "nuget_project:"], "forbiddenSecrets": ["NUGET_API_KEY"], "checks": [ { "id": "nuget.publish.oidc", "verdict": "intent", "assert": "NuGet publish uses OIDC Trusted Publishing (NuGet/login), not a stored API key.", "workflowRef": "WORKFLOW.md#d4---release--publish" }, @@ -23,7 +23,7 @@ ] }, "pypi": { - "detect": ["build-pypilibrary-task.yml", "pypa/gh-action-pypi-publish"], + "detect": [".github/actions/build-pypi/action.yml", "pypa/gh-action-pypi-publish", "pypi_project_dir:"], "forbiddenSecrets": ["PYPI_API_TOKEN", "TWINE_PASSWORD"], "checks": [ { "id": "pypi.publish.oidc", "verdict": "intent", "assert": "PyPI publish uses OIDC Trusted Publishing (no stored token).", "workflowRef": "WORKFLOW.md#d4---release--publish" }, @@ -54,11 +54,11 @@ { "id": "cpp.clangformat.shared", "verdict": "intent", "assert": "A clang-format configuration (.clang-format) drives C/C++ formatting and is shared by the editor, the CLI, and CI, feeding the operational lint gate. The scope is style only. Semantic and static analysis are left to the downstream toolchain that compiles the code (an ESPHome build), which has the compile context clang-tidy would need. A repo's .h is treated as C++ by context.", "intentRef": "CODESTYLE.md" } ] }, - "console": { - "detect": ["build-executable-task.yml", "System.CommandLine"], + "dotnet-publish": { + "detect": [".github/actions/dotnet-publish/action.yml", "System.CommandLine", "dotnet_publish_project:"], "checks": [ - { "id": "console.smoke.subset", "verdict": "letter", "assert": "The smoke runtime matrix is a strict non-empty subset of the full matrix.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, - { "id": "console.aggregate.asset", "verdict": "letter", "assert": "Per-runtime outputs are aggregated by pattern+merge-multiple into one release-asset--, gated !smoke.", "workflowRef": "WORKFLOW.md#d6---seam--architecture-conformance" } + { "id": "dotnet-publish.smoke.subset", "verdict": "letter", "assert": "The smoke runtime set is a strict non-empty subset of the full runtime set.", "workflowRef": "WORKFLOW.md#6-per-project-type-test-walkthroughs" }, + { "id": "dotnet-publish.release.asset", "verdict": "letter", "assert": "A non-smoke run uploads one release-asset--dotnet-publish artifact, while a smoke run skips archive creation and upload.", "workflowRef": "WORKFLOW.md#d6---seam--architecture-conformance" } ] }, "docker": { diff --git a/spec/scope-model.md b/spec/scope-model.md index 50eb548c..5504ea41 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` `cpp` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `hugo` `docs` | [`spec/project-types.json`][project-types] | +| project type | `csharp` `nuget` `pypi` `python` `cpp` `dotnet-publish` `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] |