diff --git a/.editorconfig b/.editorconfig
index ee8a2b72..1898c1c7 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -42,6 +42,11 @@ indent_size = 2
[.github/workflows/*.{yml,yaml}]
end_of_line = lf
+# Catalog snippet workflows mirror real workflow files (LF above); keep the snippets LF so a copied snippet
+# lands compliant instead of needing conversion.
+[catalog/snippets/workflows/*.{yml,yaml}]
+end_of_line = lf
+
# Linux scripts
[*.sh]
end_of_line = lf
diff --git a/AGENTS.md b/AGENTS.md
index a95c603b..137667f6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -124,7 +124,7 @@ Applies to code and workflow (`#`) comments alike.
### Line Endings
- **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** - every file type is CRLF unless pinned otherwise - with **LF** pinned for the execution-sensitive exceptions - `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared; the redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins.
-- **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF** - GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This is the one LF class that is **not** also a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF.
+- **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF** - GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This (and the catalog snippet workflows in `catalog/snippets/workflows/*`, pinned LF the same way) is an LF class **not** backed by a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a *Dockerfile* - interpreted, where a CR breaks `RUN` heredocs and line continuations - is LF.
- **Scripts and extensionless executables must be LF - and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing - so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** - e.g. `Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks - so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them - e.g. KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending.
- **Pair each such pin with a matching `.editorconfig` override - the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL - `[
/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section.
- **New files:** create them with the `.editorconfig`-mandated ending.
diff --git a/catalog/snippets/workflows/build-datebadge-task.yml b/catalog/snippets/workflows/build-datebadge-task.yml
index 0624ebfe..8042b526 100644
--- a/catalog/snippets/workflows/build-datebadge-task.yml
+++ b/catalog/snippets/workflows/build-datebadge-task.yml
@@ -1,29 +1,29 @@
-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.
-
-on:
- workflow_call:
-
-jobs:
-
- date-badge:
- name: Build BYOB date badge job
- runs-on: ubuntu-latest
-
- steps:
-
- - name: Get current date step
- id: date
- run: echo "date=$(date)" >> "$GITHUB_OUTPUT"
-
- - name: Build BYOB date badge step
- uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0
- with:
- name: lastbuild
- label: "Last Build"
- icon: "github"
- status: ${{ steps.date.outputs.date }}
- color: "blue"
- github_token: ${{ secrets.GITHUB_TOKEN }}
+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.
+
+on:
+ workflow_call:
+
+jobs:
+
+ date-badge:
+ name: Build BYOB date badge job
+ runs-on: ubuntu-latest
+
+ steps:
+
+ - name: Get current date step
+ id: date
+ run: echo "date=$(date)" >> "$GITHUB_OUTPUT"
+
+ - name: Build BYOB date badge step
+ uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0
+ with:
+ name: lastbuild
+ label: "Last Build"
+ icon: "github"
+ status: ${{ steps.date.outputs.date }}
+ color: "blue"
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/catalog/snippets/workflows/build-docker-task.yml b/catalog/snippets/workflows/build-docker-task.yml
index c9344410..6d5a709c 100644
--- a/catalog/snippets/workflows/build-docker-task.yml
+++ b/catalog/snippets/workflows/build-docker-task.yml
@@ -1,96 +1,96 @@
-name: Build Docker image task
-
-on:
- workflow_call:
- inputs:
- # Input to control whether to push the Docker image to Docker Hub
- push:
- required: false
- type: boolean
- default: false
- # Git ref to check out / version (empty = default checkout ref).
- ref:
- 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.
- 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:
- required: false
- type: boolean
- default: false
-
-jobs:
-
- get-version:
- name: Get version information job
- uses: ./.github/workflows/get-version-task.yml
- secrets: inherit
- with:
- ref: ${{ inputs.ref }}
-
- build-docker:
- name: Build Docker image job
- runs-on: ubuntu-latest
- needs: [get-version]
- env:
- # Multi-arch (amd64+arm64) only when publishing the main image; every other build is amd64 only.
- PLATFORMS: ${{ (inputs.push && inputs.branch == 'main') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
-
- steps:
-
- - name: Checkout step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref }}
-
- # arm64 is non-native on the amd64 runner, so 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
- with:
- platforms: linux/arm64
-
- - name: Setup Buildx step
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- 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.
- - name: Login to Docker Hub step
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
- with:
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
- password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-
- - name: Docker build and push step
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
- with:
- context: .
- push: ${{ inputs.push }}
- file: ./Docker/Dockerfile
- tags: |
- 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.
- cache-from: |
- type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main
- type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop
- cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }}
- build-args: |
- LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }}
- BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }}
- BUILD_VERSION=${{ needs.get-version.outputs.AssemblyVersion }}
- BUILD_FILE_VERSION=${{ needs.get-version.outputs.AssemblyFileVersion }}
- BUILD_ASSEMBLY_VERSION=${{ needs.get-version.outputs.AssemblyVersion }}
- BUILD_INFORMATION_VERSION=${{ needs.get-version.outputs.AssemblyInformationalVersion }}
- BUILD_PACKAGE_VERSION=${{ needs.get-version.outputs.SemVer2 }}
-
+name: Build Docker image task
+
+on:
+ workflow_call:
+ inputs:
+ # Input to control whether to push the Docker image to Docker Hub
+ push:
+ required: false
+ type: boolean
+ default: false
+ # Git ref to check out / version (empty = default checkout ref).
+ ref:
+ 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.
+ 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:
+ required: false
+ type: boolean
+ default: false
+
+jobs:
+
+ get-version:
+ name: Get version information job
+ uses: ./.github/workflows/get-version-task.yml
+ secrets: inherit
+ with:
+ ref: ${{ inputs.ref }}
+
+ build-docker:
+ name: Build Docker image job
+ runs-on: ubuntu-latest
+ needs: [get-version]
+ env:
+ # Multi-arch (amd64+arm64) only when publishing the main image; every other build is amd64 only.
+ PLATFORMS: ${{ (inputs.push && inputs.branch == 'main') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
+
+ steps:
+
+ - name: Checkout step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref }}
+
+ # arm64 is non-native on the amd64 runner, so 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
+ with:
+ platforms: linux/arm64
+
+ - name: Setup Buildx step
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ 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.
+ - name: Login to Docker Hub step
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+ - name: Docker build and push step
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+ with:
+ context: .
+ push: ${{ inputs.push }}
+ file: ./Docker/Dockerfile
+ tags: |
+ 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.
+ cache-from: |
+ type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-main
+ type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-develop
+ cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/projecttemplate:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }}
+ build-args: |
+ LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }}
+ BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }}
+ BUILD_VERSION=${{ needs.get-version.outputs.AssemblyVersion }}
+ BUILD_FILE_VERSION=${{ needs.get-version.outputs.AssemblyFileVersion }}
+ BUILD_ASSEMBLY_VERSION=${{ needs.get-version.outputs.AssemblyVersion }}
+ BUILD_INFORMATION_VERSION=${{ needs.get-version.outputs.AssemblyInformationalVersion }}
+ BUILD_PACKAGE_VERSION=${{ needs.get-version.outputs.SemVer2 }}
+
diff --git a/catalog/snippets/workflows/build-executable-task.yml b/catalog/snippets/workflows/build-executable-task.yml
index ab5add7e..b18a3d6f 100644
--- a/catalog/snippets/workflows/build-executable-task.yml
+++ b/catalog/snippets/workflows/build-executable-task.yml
@@ -1,106 +1,106 @@
-name: Build executable task
-
-on:
- workflow_call:
- inputs:
- # Git ref to check out / version (empty = default checkout ref).
- ref:
- 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.
- 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:
- required: false
- type: boolean
- default: false
-
-jobs:
-
- get-version:
- name: Get version information job
- uses: ./.github/workflows/get-version-task.yml
- secrets: inherit
- with:
- ref: ${{ inputs.ref }}
-
- build-executable-matrix:
- name: Build executable project matrix job
- runs-on: ubuntu-latest
- needs: [get-version]
- strategy:
- matrix:
- runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }}
-
- steps:
-
- # No NuGet restore caching: restore is cheap here, and setup-dotnet's cache needs a packages.lock.json that
- # Central Package Management doesn't produce by default.
- - name: Setup .NET SDK step
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
- with:
- dotnet-version: 10.x
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref }}
-
- - name: Build executable project step
- run: |
- dotnet publish ./Console/Console.csproj \
- --runtime ${{ matrix.runtime }} \
- -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \
- --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \
- -property:PublishAot=false \
- -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \
- -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \
- -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
- -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.
- - name: Upload matrix build artifacts step
- if: ${{ !inputs.smoke }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: publish-${{ inputs.branch }}-${{ matrix.runtime }}
- path: ${{ runner.temp }}/publish
- # 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.
- upload-build-artifacts:
- name: Upload matrix build artifacts job
- if: ${{ !inputs.smoke }}
- runs-on: ubuntu-latest
- needs: [ build-executable-matrix ]
-
- steps:
-
- - name: Download matrix build artifacts step
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- pattern: publish-${{ inputs.branch }}-*
- merge-multiple: true
- path: ${{ runner.temp }}/publish
-
- - 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.
- - name: Upload build artifacts step
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: release-asset-${{ inputs.branch }}-executable
- path: ${{ runner.temp }}/Console.7z
- # Consumed within this run by the github-release job; minimize artifact storage.
- retention-days: 1
+name: Build executable task
+
+on:
+ workflow_call:
+ inputs:
+ # Git ref to check out / version (empty = default checkout ref).
+ ref:
+ 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.
+ 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:
+ required: false
+ type: boolean
+ default: false
+
+jobs:
+
+ get-version:
+ name: Get version information job
+ uses: ./.github/workflows/get-version-task.yml
+ secrets: inherit
+ with:
+ ref: ${{ inputs.ref }}
+
+ build-executable-matrix:
+ name: Build executable project matrix job
+ runs-on: ubuntu-latest
+ needs: [get-version]
+ strategy:
+ matrix:
+ runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }}
+
+ steps:
+
+ # No NuGet restore caching: restore is cheap here, and setup-dotnet's cache needs a packages.lock.json that
+ # Central Package Management doesn't produce by default.
+ - name: Setup .NET SDK step
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ with:
+ dotnet-version: 10.x
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Build executable project step
+ run: |
+ dotnet publish ./Console/Console.csproj \
+ --runtime ${{ matrix.runtime }} \
+ -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \
+ --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \
+ -property:PublishAot=false \
+ -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \
+ -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \
+ -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
+ -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.
+ - name: Upload matrix build artifacts step
+ if: ${{ !inputs.smoke }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: publish-${{ inputs.branch }}-${{ matrix.runtime }}
+ path: ${{ runner.temp }}/publish
+ # 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.
+ upload-build-artifacts:
+ name: Upload matrix build artifacts job
+ if: ${{ !inputs.smoke }}
+ runs-on: ubuntu-latest
+ needs: [ build-executable-matrix ]
+
+ steps:
+
+ - name: Download matrix build artifacts step
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: publish-${{ inputs.branch }}-*
+ merge-multiple: true
+ path: ${{ runner.temp }}/publish
+
+ - 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.
+ - name: Upload build artifacts step
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-asset-${{ inputs.branch }}-executable
+ path: ${{ runner.temp }}/Console.7z
+ # Consumed within this run by the github-release job; minimize artifact storage.
+ retention-days: 1
diff --git a/catalog/snippets/workflows/build-nugetlibrary-task.yml b/catalog/snippets/workflows/build-nugetlibrary-task.yml
index a0311beb..fc21ba61 100644
--- a/catalog/snippets/workflows/build-nugetlibrary-task.yml
+++ b/catalog/snippets/workflows/build-nugetlibrary-task.yml
@@ -1,89 +1,89 @@
-name: Build NuGet library task
-
-on:
- workflow_call:
- inputs:
- # Whether to push the NuGet library to NuGet.org.
- push:
- required: false
- type: boolean
- default: false
- # Git ref to check out / version (empty = default checkout ref).
- ref:
- 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.
- 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:
- required: false
- type: boolean
- default: false
-
-jobs:
-
- get-version:
- name: Get version information job
- uses: ./.github/workflows/get-version-task.yml
- with:
- ref: ${{ inputs.ref }}
-
- build-nugetlibrary:
- name: Build NuGet library project job
- runs-on: ubuntu-latest
- needs: [get-version]
-
- steps:
-
- - name: Setup .NET SDK step
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
- with:
- dotnet-version: 10.x
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref }}
-
- - name: Build NuGet library project step
- run: |
- set -euo pipefail
- dotnet build ./NuGetLibrary/NuGetLibrary.csproj \
- -property:OutputPath=${{ runner.temp }}/publish/ \
- -property:PackageOutputPath=${{ runner.temp }}/publish/ \
- --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \
- -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \
- -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \
- -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
- -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \
- -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }}
-
- - name: Publish to NuGet.org step
- if: ${{ inputs.push }}
- run: |
- set -euo pipefail
- dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \
- --source https://api.nuget.org/v3/index.json \
- --api-key ${{ secrets.NUGET_API_KEY }} \
- --skip-duplicate
-
- - name: Zip output step
- 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.
- - name: Upload build artifacts step
- if: ${{ !inputs.smoke }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: release-asset-${{ inputs.branch }}-nugetlibrary
- path: ${{ runner.temp }}/NuGetLibrary.7z
- # Consumed within this run by the github-release job; minimize artifact storage.
- retention-days: 1
+name: Build NuGet library task
+
+on:
+ workflow_call:
+ inputs:
+ # Whether to push the NuGet library to NuGet.org.
+ push:
+ required: false
+ type: boolean
+ default: false
+ # Git ref to check out / version (empty = default checkout ref).
+ ref:
+ 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.
+ 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:
+ required: false
+ type: boolean
+ default: false
+
+jobs:
+
+ get-version:
+ name: Get version information job
+ uses: ./.github/workflows/get-version-task.yml
+ with:
+ ref: ${{ inputs.ref }}
+
+ build-nugetlibrary:
+ name: Build NuGet library project job
+ runs-on: ubuntu-latest
+ needs: [get-version]
+
+ steps:
+
+ - name: Setup .NET SDK step
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ with:
+ dotnet-version: 10.x
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Build NuGet library project step
+ run: |
+ set -euo pipefail
+ dotnet build ./NuGetLibrary/NuGetLibrary.csproj \
+ -property:OutputPath=${{ runner.temp }}/publish/ \
+ -property:PackageOutputPath=${{ runner.temp }}/publish/ \
+ --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \
+ -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \
+ -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \
+ -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
+ -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \
+ -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }}
+
+ - name: Publish to NuGet.org step
+ if: ${{ inputs.push }}
+ run: |
+ set -euo pipefail
+ dotnet nuget push ${{ runner.temp }}/publish/*.nupkg \
+ --source https://api.nuget.org/v3/index.json \
+ --api-key ${{ secrets.NUGET_API_KEY }} \
+ --skip-duplicate
+
+ - name: Zip output step
+ 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.
+ - name: Upload build artifacts step
+ if: ${{ !inputs.smoke }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-asset-${{ inputs.branch }}-nugetlibrary
+ path: ${{ runner.temp }}/NuGetLibrary.7z
+ # Consumed within this run by the github-release job; minimize artifact storage.
+ retention-days: 1
diff --git a/catalog/snippets/workflows/build-pypilibrary-task.yml b/catalog/snippets/workflows/build-pypilibrary-task.yml
index 61885a54..5cc0b71e 100644
--- a/catalog/snippets/workflows/build-pypilibrary-task.yml
+++ b/catalog/snippets/workflows/build-pypilibrary-task.yml
@@ -1,121 +1,121 @@
-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).
-
-on:
- workflow_call:
- inputs:
- # Git ref to check out / version (empty = default checkout ref).
- ref:
- required: false
- type: string
- default: ''
- # Logical branch driving the PEP 440 version (develop => .dev0 prerelease, otherwise plain release). Required; 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:
- required: false
- type: boolean
- default: false
- outputs:
- artifact-name:
- value: ${{ jobs.build-pypilibrary.outputs.artifact-name }}
- artifact-id:
- value: ${{ jobs.build-pypilibrary.outputs.artifact-id }}
-
-jobs:
-
- get-version:
- name: Get version information job
- uses: ./.github/workflows/get-version-task.yml
- with:
- ref: ${{ inputs.ref }}
-
- build-pypilibrary:
- name: Build PyPI library project job
- runs-on: ubuntu-latest
- needs: [get-version]
- defaults:
- run:
- working-directory: ./PyPiLibrary
- outputs:
- artifact-name: pypilibrary-build-${{ inputs.branch }}
- artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }}
-
- steps:
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref }}
-
- - 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.
- version: "0.11.8"
- enable-cache: true
- cache-dependency-glob: "PyPiLibrary/uv.lock"
-
- - name: Sync dependencies step
- run: uv sync --all-groups --frozen
-
- - name: Lint with ruff step
- run: uv run ruff check
-
- - name: Verify formatting with ruff step
- run: uv run ruff format --check
-
- - name: Type check with pyright step
- run: uv run pyright
-
- - 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.
- - name: Compute PyPI version step
- id: pypiver
- run: |
- set -euo pipefail
- if [[ "$BRANCH" == "develop" ]]; then
- version="${AFV}.dev0"
- else
- version="$AFV"
- fi
- echo "PyPI version for branch $BRANCH: $version"
- echo "version=$version" >> "$GITHUB_OUTPUT"
- env:
- AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }}
- BRANCH: ${{ inputs.branch }}
-
- # Rewrite the hardcoded __version__ in _version.py (the single source hatchling reads) with the computed version.
- # Done after tests so the test asserting __version__ is non-empty isn't affected.
- - name: Write version into _version.py step
- run: |
- set -euo pipefail
- sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py
- env:
- VERSION: ${{ steps.pypiver.outputs.version }}
-
- - name: Build sdist and wheel step
- run: uv build
-
- # Branch-suffixed so the publisher's branch matrix can build both branches in one run without colliding.
- # Skipped on smoke: the publish-pypi job runs only on a real publish, so nothing would consume it.
- - name: Upload build artifacts step
- id: artifact-upload-step
- if: ${{ !inputs.smoke }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: pypilibrary-build-${{ inputs.branch }}
- path: PyPiLibrary/dist/*
- # Consumed within this run by the publish-pypi job; minimize artifact storage.
- retention-days: 1
+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).
+
+on:
+ workflow_call:
+ inputs:
+ # Git ref to check out / version (empty = default checkout ref).
+ ref:
+ required: false
+ type: string
+ default: ''
+ # Logical branch driving the PEP 440 version (develop => .dev0 prerelease, otherwise plain release). Required; 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:
+ required: false
+ type: boolean
+ default: false
+ outputs:
+ artifact-name:
+ value: ${{ jobs.build-pypilibrary.outputs.artifact-name }}
+ artifact-id:
+ value: ${{ jobs.build-pypilibrary.outputs.artifact-id }}
+
+jobs:
+
+ get-version:
+ name: Get version information job
+ uses: ./.github/workflows/get-version-task.yml
+ with:
+ ref: ${{ inputs.ref }}
+
+ build-pypilibrary:
+ name: Build PyPI library project job
+ runs-on: ubuntu-latest
+ needs: [get-version]
+ defaults:
+ run:
+ working-directory: ./PyPiLibrary
+ outputs:
+ artifact-name: pypilibrary-build-${{ inputs.branch }}
+ artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }}
+
+ steps:
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref }}
+
+ - 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.
+ version: "0.11.8"
+ enable-cache: true
+ cache-dependency-glob: "PyPiLibrary/uv.lock"
+
+ - name: Sync dependencies step
+ run: uv sync --all-groups --frozen
+
+ - name: Lint with ruff step
+ run: uv run ruff check
+
+ - name: Verify formatting with ruff step
+ run: uv run ruff format --check
+
+ - name: Type check with pyright step
+ run: uv run pyright
+
+ - 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.
+ - name: Compute PyPI version step
+ id: pypiver
+ run: |
+ set -euo pipefail
+ if [[ "$BRANCH" == "develop" ]]; then
+ version="${AFV}.dev0"
+ else
+ version="$AFV"
+ fi
+ echo "PyPI version for branch $BRANCH: $version"
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ env:
+ AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }}
+ BRANCH: ${{ inputs.branch }}
+
+ # Rewrite the hardcoded __version__ in _version.py (the single source hatchling reads) with the computed version.
+ # Done after tests so the test asserting __version__ is non-empty isn't affected.
+ - name: Write version into _version.py step
+ run: |
+ set -euo pipefail
+ sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py
+ env:
+ VERSION: ${{ steps.pypiver.outputs.version }}
+
+ - name: Build sdist and wheel step
+ run: uv build
+
+ # Branch-suffixed so the publisher's branch matrix can build both branches in one run without colliding.
+ # Skipped on smoke: the publish-pypi job runs only on a real publish, so nothing would consume it.
+ - name: Upload build artifacts step
+ id: artifact-upload-step
+ if: ${{ !inputs.smoke }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: pypilibrary-build-${{ inputs.branch }}
+ path: PyPiLibrary/dist/*
+ # Consumed within this run by the publish-pypi job; minimize artifact storage.
+ retention-days: 1
diff --git a/catalog/snippets/workflows/build-release-task.yml b/catalog/snippets/workflows/build-release-task.yml
index 4333a42d..9fd33611 100644
--- a/catalog/snippets/workflows/build-release-task.yml
+++ b/catalog/snippets/workflows/build-release-task.yml
@@ -1,252 +1,252 @@
-name: Build project release task
-
-on:
- workflow_call:
- inputs:
- # Whether to create a GitHub release.
- github:
- required: false
- type: boolean
- default: false
- # Whether to push the library to NuGet.org.
- nuget:
- required: false
- type: boolean
- default: false
- # Whether to push the docker image to Docker Hub.
- dockerhub:
- required: false
- type: boolean
- default: false
- # Git ref to check out / version (empty = default checkout ref).
- ref:
- required: false
- type: string
- default: ''
- # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because the
- # publisher builds both `main` and `develop` in one run, so a silent fallback would mislabel the develop leg.
- 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:
- 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.
- enable_docker:
- required: false
- type: boolean
- default: true
- enable_nuget:
- required: false
- type: boolean
- default: true
- enable_pypi:
- required: false
- type: boolean
- default: true
- enable_executable:
- 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.
- expect_release_assets:
- required: false
- type: boolean
- default: true
-
-jobs:
-
- get-version:
- name: Get version information job
- uses: ./.github/workflows/get-version-task.yml
- secrets: inherit
- 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.
- validate-release:
- name: Validate release version job
- needs: [get-version]
- runs-on: ubuntu-latest
- steps:
- - name: Validate branch and version consistency step
- env:
- SEMVER2: ${{ needs.get-version.outputs.SemVer2 }}
- BRANCH: ${{ inputs.branch }}
- SMOKE: ${{ inputs.smoke }}
- run: |
- set -euo pipefail
- # Smoke builds never publish and always version as prerelease (detached PR HEAD), which would trip the main arm.
- if [[ "$SMOKE" == "true" ]]; then
- echo "Smoke build; skipping release version validation."
- exit 0
- fi
- CORE_AND_PRE="${SEMVER2%%+*}"
- if [[ "$BRANCH" == "main" ]]; then
- if [[ "$CORE_AND_PRE" == *-* ]]; then
- echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish."
- exit 1
- fi
- elif [[ "$CORE_AND_PRE" != *-* ]]; then
- echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish."
- exit 1
- fi
-
- build-nugetlibrary:
- name: Build NuGet library job
- if: ${{ inputs.enable_nuget }}
- needs: [get-version, validate-release]
- uses: ./.github/workflows/build-nugetlibrary-task.yml
- secrets: inherit
- with:
- # Pin to the resolved commit so the artifacts match the release tag even if the branch advances mid-run.
- ref: ${{ needs.get-version.outputs.GitCommitId }}
- branch: ${{ inputs.branch }}
- # Push to NuGet.org, never on a smoke build.
- push: ${{ inputs.nuget && !inputs.smoke }}
- # 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.
- build-pypilibrary:
- name: Build PyPI library job
- if: ${{ inputs.enable_pypi }}
- needs: [get-version, validate-release]
- uses: ./.github/workflows/build-pypilibrary-task.yml
- secrets: inherit
- with:
- # Pin to the resolved commit so the artifacts match the release tag.
- ref: ${{ needs.get-version.outputs.GitCommitId }}
- branch: ${{ inputs.branch }}
- # Skip the wheel/sdist upload on smoke (the publish-pypi job runs only on a real publish).
- smoke: ${{ inputs.smoke }}
-
- build-executable:
- name: Build executable job
- if: ${{ inputs.enable_executable }}
- needs: [get-version, validate-release]
- uses: ./.github/workflows/build-executable-task.yml
- secrets: inherit
- with:
- # Pin to the resolved commit so the artifacts match the release tag.
- ref: ${{ needs.get-version.outputs.GitCommitId }}
- branch: ${{ inputs.branch }}
- smoke: ${{ inputs.smoke }}
-
- build-docker:
- name: Build Docker job
- if: ${{ inputs.enable_docker }}
- needs: [get-version, validate-release]
- uses: ./.github/workflows/build-docker-task.yml
- secrets: inherit
- with:
- # Pin to the resolved commit so the artifacts match the release tag.
- ref: ${{ needs.get-version.outputs.GitCommitId }}
- branch: ${{ inputs.branch }}
- smoke: ${{ inputs.smoke }}
- # Push to Docker Hub, never on a smoke build.
- push: ${{ inputs.dockerhub && !inputs.smoke }}
-
- github-release:
- name: Publish GitHub release job
- # `!inputs.smoke` enforces "smoke never publishes" at the job level, so a smoke caller that also set
- # `github: true` still can't create a release.
- if: ${{ inputs.github && !inputs.smoke }}
- runs-on: ubuntu-latest
- needs: [get-version, validate-release, build-nugetlibrary, build-pypilibrary, build-executable, build-docker]
-
- steps:
-
- # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run.
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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.
- - name: Download release asset artifacts step
- if: ${{ inputs.expect_release_assets }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- pattern: release-asset-${{ inputs.branch }}-*
- 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.
- - name: Check for existing release step
- id: release-exists
- env:
- GH_TOKEN: ${{ github.token }}
- TAG: ${{ needs.get-version.outputs.SemVer2 }}
- run: |
- set -euo pipefail
- if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
- echo "exists=true" >> "$GITHUB_OUTPUT"
- if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
- echo "Release $TAG already exists; workflow_dispatch will refresh it."
- else
- echo "Release $TAG already exists; skipping release creation (no-op republish)."
- fi
- else
- echo "exists=false" >> "$GITHUB_OUTPUT"
- fi
-
- # `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).
- - 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
- with:
- generate_release_notes: true
- tag_name: ${{ needs.get-version.outputs.SemVer2 }}
- target_commitish: ${{ needs.get-version.outputs.GitCommitId }}
- prerelease: ${{ inputs.branch != 'main' }}
- fail_on_unmatched_files: ${{ inputs.expect_release_assets }}
- files: |
- LICENSE
- 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).
- - 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).
- continue-on-error: true
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- set -euo pipefail
- if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \
- --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then
- echo "::warning::Could not list run artifacts; retention-days backstop will reap them."
- ids=""
- fi
- for id in $ids; do
- gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \
- || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it."
- done
+name: Build project release task
+
+on:
+ workflow_call:
+ inputs:
+ # Whether to create a GitHub release.
+ github:
+ required: false
+ type: boolean
+ default: false
+ # Whether to push the library to NuGet.org.
+ nuget:
+ required: false
+ type: boolean
+ default: false
+ # Whether to push the docker image to Docker Hub.
+ dockerhub:
+ required: false
+ type: boolean
+ default: false
+ # Git ref to check out / version (empty = default checkout ref).
+ ref:
+ required: false
+ type: string
+ default: ''
+ # Logical branch driving config / tags / prerelease for every target. Required (no fallback) because the
+ # publisher builds both `main` and `develop` in one run, so a silent fallback would mislabel the develop leg.
+ 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:
+ 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.
+ enable_docker:
+ required: false
+ type: boolean
+ default: true
+ enable_nuget:
+ required: false
+ type: boolean
+ default: true
+ enable_pypi:
+ required: false
+ type: boolean
+ default: true
+ enable_executable:
+ 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.
+ expect_release_assets:
+ required: false
+ type: boolean
+ default: true
+
+jobs:
+
+ get-version:
+ name: Get version information job
+ uses: ./.github/workflows/get-version-task.yml
+ secrets: inherit
+ 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.
+ validate-release:
+ name: Validate release version job
+ needs: [get-version]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Validate branch and version consistency step
+ env:
+ SEMVER2: ${{ needs.get-version.outputs.SemVer2 }}
+ BRANCH: ${{ inputs.branch }}
+ SMOKE: ${{ inputs.smoke }}
+ run: |
+ set -euo pipefail
+ # Smoke builds never publish and always version as prerelease (detached PR HEAD), which would trip the main arm.
+ if [[ "$SMOKE" == "true" ]]; then
+ echo "Smoke build; skipping release version validation."
+ exit 0
+ fi
+ CORE_AND_PRE="${SEMVER2%%+*}"
+ if [[ "$BRANCH" == "main" ]]; then
+ if [[ "$CORE_AND_PRE" == *-* ]]; then
+ echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish."
+ exit 1
+ fi
+ elif [[ "$CORE_AND_PRE" != *-* ]]; then
+ echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish."
+ exit 1
+ fi
+
+ build-nugetlibrary:
+ name: Build NuGet library job
+ if: ${{ inputs.enable_nuget }}
+ needs: [get-version, validate-release]
+ uses: ./.github/workflows/build-nugetlibrary-task.yml
+ secrets: inherit
+ with:
+ # Pin to the resolved commit so the artifacts match the release tag even if the branch advances mid-run.
+ ref: ${{ needs.get-version.outputs.GitCommitId }}
+ branch: ${{ inputs.branch }}
+ # Push to NuGet.org, never on a smoke build.
+ push: ${{ inputs.nuget && !inputs.smoke }}
+ # 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.
+ build-pypilibrary:
+ name: Build PyPI library job
+ if: ${{ inputs.enable_pypi }}
+ needs: [get-version, validate-release]
+ uses: ./.github/workflows/build-pypilibrary-task.yml
+ secrets: inherit
+ with:
+ # Pin to the resolved commit so the artifacts match the release tag.
+ ref: ${{ needs.get-version.outputs.GitCommitId }}
+ branch: ${{ inputs.branch }}
+ # Skip the wheel/sdist upload on smoke (the publish-pypi job runs only on a real publish).
+ smoke: ${{ inputs.smoke }}
+
+ build-executable:
+ name: Build executable job
+ if: ${{ inputs.enable_executable }}
+ needs: [get-version, validate-release]
+ uses: ./.github/workflows/build-executable-task.yml
+ secrets: inherit
+ with:
+ # Pin to the resolved commit so the artifacts match the release tag.
+ ref: ${{ needs.get-version.outputs.GitCommitId }}
+ branch: ${{ inputs.branch }}
+ smoke: ${{ inputs.smoke }}
+
+ build-docker:
+ name: Build Docker job
+ if: ${{ inputs.enable_docker }}
+ needs: [get-version, validate-release]
+ uses: ./.github/workflows/build-docker-task.yml
+ secrets: inherit
+ with:
+ # Pin to the resolved commit so the artifacts match the release tag.
+ ref: ${{ needs.get-version.outputs.GitCommitId }}
+ branch: ${{ inputs.branch }}
+ smoke: ${{ inputs.smoke }}
+ # Push to Docker Hub, never on a smoke build.
+ push: ${{ inputs.dockerhub && !inputs.smoke }}
+
+ github-release:
+ name: Publish GitHub release job
+ # `!inputs.smoke` enforces "smoke never publishes" at the job level, so a smoke caller that also set
+ # `github: true` still can't create a release.
+ if: ${{ inputs.github && !inputs.smoke }}
+ runs-on: ubuntu-latest
+ needs: [get-version, validate-release, build-nugetlibrary, build-pypilibrary, build-executable, build-docker]
+
+ steps:
+
+ # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run.
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ 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.
+ - name: Download release asset artifacts step
+ if: ${{ inputs.expect_release_assets }}
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: release-asset-${{ inputs.branch }}-*
+ 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.
+ - name: Check for existing release step
+ id: release-exists
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ needs.get-version.outputs.SemVer2 }}
+ run: |
+ set -euo pipefail
+ if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "Release $TAG already exists; workflow_dispatch will refresh it."
+ else
+ echo "Release $TAG already exists; skipping release creation (no-op republish)."
+ fi
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ # `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).
+ - 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
+ with:
+ generate_release_notes: true
+ tag_name: ${{ needs.get-version.outputs.SemVer2 }}
+ target_commitish: ${{ needs.get-version.outputs.GitCommitId }}
+ prerelease: ${{ inputs.branch != 'main' }}
+ fail_on_unmatched_files: ${{ inputs.expect_release_assets }}
+ files: |
+ LICENSE
+ 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).
+ - 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).
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \
+ --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then
+ echo "::warning::Could not list run artifacts; retention-days backstop will reap them."
+ ids=""
+ fi
+ for id in $ids; do
+ gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id" \
+ || echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it."
+ done
diff --git a/catalog/snippets/workflows/check-upstream-version-task.yml b/catalog/snippets/workflows/check-upstream-version-task.yml
index 72fab1e6..c4962d58 100644
--- a/catalog/snippets/workflows/check-upstream-version-task.yml
+++ b/catalog/snippets/workflows/check-upstream-version-task.yml
@@ -1,133 +1,133 @@
-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"]).
-
-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"}.
- description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
- required: true
- type: string
- state-file:
- description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
- required: false
- type: string
- default: upstream-version.json
- branches:
- description: JSON array of base branches to open bump PRs against.
- required: false
- 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.
- description: Head-branch prefix; must match the merge-bot's upstream-version- head refs.
- required: false
- type: string
- default: upstream-version
-
-jobs:
-
- check-upstream-version:
- name: Check upstream version job
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- branch: ${{ fromJSON(inputs.branches) }}
- permissions:
- contents: write
- pull-requests: write
-
- steps:
-
- # App token so the merge commit fires downstream workflows and the PR is signed.
- - name: Generate GitHub App token step
- id: app-token
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
- with:
- client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
- private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- 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.
- - name: Resolve upstream version step
- id: resolve
- env:
- RESOLVER_COMMAND: ${{ inputs.resolver-command }}
- STATE_FILE: ${{ inputs.state-file }}
- run: |
- set -euo 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.
- 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
- echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
- 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).
- 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.
- 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).
- 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.
- 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
- title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
- else
- title="Update upstream versions: $summary"
- fi
- {
- echo "title=$title"
- echo "body<> "$GITHUB_OUTPUT"
-
- # Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
- - name: Open bump pull request step
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
- with:
- token: ${{ steps.app-token.outputs.token }}
- base: ${{ matrix.branch }}
- branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
- title: ${{ steps.resolve.outputs.title }}
- commit-message: ${{ steps.resolve.outputs.title }}
- body: ${{ steps.resolve.outputs.body }}
- sign-commits: true
- delete-branch: true
+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"]).
+
+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"}.
+ description: Shell command that prints the resolved upstream version(s) as a JSON object of name -> version to stdout.
+ required: true
+ type: string
+ state-file:
+ description: Committed version-state file (a JSON object of name -> version), at the repo root beside version.json.
+ required: false
+ type: string
+ default: upstream-version.json
+ branches:
+ description: JSON array of base branches to open bump PRs against.
+ required: false
+ 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.
+ description: Head-branch prefix; must match the merge-bot's upstream-version- head refs.
+ required: false
+ type: string
+ default: upstream-version
+
+jobs:
+
+ check-upstream-version:
+ name: Check upstream version job
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ branch: ${{ fromJSON(inputs.branches) }}
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+
+ # App token so the merge commit fires downstream workflows and the PR is signed.
+ - name: Generate GitHub App token step
+ id: app-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
+ private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ 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.
+ - name: Resolve upstream version step
+ id: resolve
+ env:
+ RESOLVER_COMMAND: ${{ inputs.resolver-command }}
+ STATE_FILE: ${{ inputs.state-file }}
+ run: |
+ set -euo 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.
+ 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
+ echo "Resolver must print a non-empty JSON object of single-line name -> version strings, no CR/LF (single-version wrappers print {\"version\":\"X\"}); got: $raw" >&2
+ 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).
+ 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.
+ 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).
+ 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.
+ 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
+ title="Update upstream version to $(printf '%s' "$new" | jq -r '.version')"
+ else
+ title="Update upstream versions: $summary"
+ fi
+ {
+ echo "title=$title"
+ echo "body<> "$GITHUB_OUTPUT"
+
+ # Rolling PR: signed by the API (satisfies Require signed commits), auto-merged by the merge-bot.
+ - name: Open bump pull request step
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+ with:
+ token: ${{ steps.app-token.outputs.token }}
+ base: ${{ matrix.branch }}
+ branch: ${{ inputs.bump-branch-prefix }}-${{ matrix.branch }}
+ title: ${{ steps.resolve.outputs.title }}
+ commit-message: ${{ steps.resolve.outputs.title }}
+ body: ${{ steps.resolve.outputs.body }}
+ sign-commits: true
+ delete-branch: true
diff --git a/catalog/snippets/workflows/get-version-task.yml b/catalog/snippets/workflows/get-version-task.yml
index d795213d..2d0321ca 100644
--- a/catalog/snippets/workflows/get-version-task.yml
+++ b/catalog/snippets/workflows/get-version-task.yml
@@ -1,60 +1,60 @@
-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`.
- ref:
- required: false
- type: string
- default: ''
- outputs:
- SemVer2:
- value: ${{ jobs.get-version.outputs.SemVer2 }}
- AssemblyVersion:
- value: ${{ jobs.get-version.outputs.AssemblyVersion }}
- AssemblyFileVersion:
- value: ${{ jobs.get-version.outputs.AssemblyFileVersion }}
- AssemblyInformationalVersion:
- value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }}
- # Full SHA of the commit the version was computed from, used to pin the release tag to the exact built commit.
- GitCommitId:
- value: ${{ jobs.get-version.outputs.GitCommitId }}
-
-jobs:
-
- get-version:
- name: Get version information job
- runs-on: ubuntu-latest
- outputs:
- SemVer2: ${{ steps.nbgv.outputs.SemVer2 }}
- AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }}
- AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }}
- AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }}
- GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }}
-
- steps:
-
- - name: Setup .NET SDK step
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
- with:
- dotnet-version: 10.x
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref }}
- fetch-depth: 0
-
- # nbgv is floated on @master: its tag stream lags master, so Dependabot tag-tracking would 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.
- IGNORE_GITHUB_REF: "true"
+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`.
+ ref:
+ required: false
+ type: string
+ default: ''
+ outputs:
+ SemVer2:
+ value: ${{ jobs.get-version.outputs.SemVer2 }}
+ AssemblyVersion:
+ value: ${{ jobs.get-version.outputs.AssemblyVersion }}
+ AssemblyFileVersion:
+ value: ${{ jobs.get-version.outputs.AssemblyFileVersion }}
+ AssemblyInformationalVersion:
+ value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }}
+ # Full SHA of the commit the version was computed from, used to pin the release tag to the exact built commit.
+ GitCommitId:
+ value: ${{ jobs.get-version.outputs.GitCommitId }}
+
+jobs:
+
+ get-version:
+ name: Get version information job
+ runs-on: ubuntu-latest
+ outputs:
+ SemVer2: ${{ steps.nbgv.outputs.SemVer2 }}
+ AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }}
+ AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }}
+ AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }}
+ GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }}
+
+ steps:
+
+ - name: Setup .NET SDK step
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ with:
+ dotnet-version: 10.x
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref }}
+ fetch-depth: 0
+
+ # nbgv is floated on @master: its tag stream lags master, so Dependabot tag-tracking would 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.
+ 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 785a2026..a4cb3f20 100644
--- a/catalog/snippets/workflows/publish-docker-readme-task.yml
+++ b/catalog/snippets/workflows/publish-docker-readme-task.yml
@@ -1,146 +1,146 @@
-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.
-# 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:
- 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`).
- repositories:
- required: false
- type: string
- default: ''
- # Optional: derive the repository list from a checked-out manifest file (used when `repositories` is empty), e.g.
- # './Make/Matrix.json'. `manifest-jq` is the jq program that turns it into the JSON array of repo names.
- manifest:
- required: false
- type: string
- default: ''
- manifest-jq:
- 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.
- transform-run:
- required: false
- type: string
- default: ''
- # Optional same-run workflow artifact to download before the transform (e.g. version includes the m4 render needs).
- # Using this requires the caller to grant `permissions: actions: read` so download-artifact can read the run's artifacts.
- transform-artifact:
- 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-filepath:
- required: false
- type: string
- default: ./Docker/README.md
-
-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.
- get-repos:
- name: Get repository list job
- runs-on: ubuntu-latest
- outputs:
- repositories: ${{ steps.list.outputs.repositories }}
-
- 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).
- - name: Validate inputs step
- env:
- REPOSITORIES: ${{ inputs.repositories }}
- MANIFEST: ${{ inputs.manifest }}
- MANIFEST_JQ: ${{ inputs.manifest-jq }}
- run: |
- set -euo pipefail
- if [ -n "$REPOSITORIES" ] && [ -n "$MANIFEST" ]; then
- echo "::error::Pass either 'repositories' or 'manifest', not both." >&2
- exit 1
- fi
- if { [ -n "$MANIFEST" ] && [ -z "$MANIFEST_JQ" ]; } || { [ -z "$MANIFEST" ] && [ -n "$MANIFEST_JQ" ]; }; then
- echo "::error::'manifest' and 'manifest-jq' must be set together." >&2
- exit 1
- fi
-
- - name: Checkout code step
- # Only the manifest path needs the tree; the static + default paths don't check out.
- if: ${{ inputs.repositories == '' && inputs.manifest != '' }}
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref || github.ref }}
-
- - name: Resolve repository list step
- id: list
- env:
- REPOSITORIES: ${{ inputs.repositories }}
- MANIFEST: ${{ inputs.manifest }}
- MANIFEST_JQ: ${{ inputs.manifest-jq }}
- run: |
- set -euo pipefail
- # Inputs validated above: at most one of repositories / manifest is set, and manifest implies manifest-jq.
- if [ -n "$REPOSITORIES" ]; then
- echo "repositories=$REPOSITORIES" >> "$GITHUB_OUTPUT"
- 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.
- echo "repositories=[\"$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')\"]" >> "$GITHUB_OUTPUT"
- fi
-
- publish-readme:
- name: Publish Docker Hub readme job
- runs-on: ubuntu-latest
- needs: get-repos
- strategy:
- matrix:
- repository: ${{ fromJSON(needs.get-repos.outputs.repositories) }}
-
- steps:
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ inputs.ref || github.ref }}
-
- # Optional: pull a same-run artifact the transform depends on (e.g. version includes for an m4 render).
- - name: Download transform artifact step
- if: ${{ inputs.transform-artifact != '' }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: ${{ inputs.transform-artifact }}
- path: ${{ runner.temp }}/transform
-
- # Optional readme render (e.g. m4 with version includes) before pushing.
- - name: Generate readme step
- if: ${{ inputs.transform-run != '' }}
- run: |
- set -euo pipefail
- ${{ inputs.transform-run }}
-
- - name: Publish Docker Hub readme step
- uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
- with:
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
- password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- repository: ${{ matrix.repository }}
- short-description: ${{ github.event.repository.description }}
- readme-filepath: ${{ inputs.readme-filepath }}
+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.
+# 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:
+ 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`).
+ repositories:
+ required: false
+ type: string
+ default: ''
+ # Optional: derive the repository list from a checked-out manifest file (used when `repositories` is empty), e.g.
+ # './Make/Matrix.json'. `manifest-jq` is the jq program that turns it into the JSON array of repo names.
+ manifest:
+ required: false
+ type: string
+ default: ''
+ manifest-jq:
+ 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.
+ transform-run:
+ required: false
+ type: string
+ default: ''
+ # Optional same-run workflow artifact to download before the transform (e.g. version includes the m4 render needs).
+ # Using this requires the caller to grant `permissions: actions: read` so download-artifact can read the run's artifacts.
+ transform-artifact:
+ 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-filepath:
+ required: false
+ type: string
+ default: ./Docker/README.md
+
+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.
+ get-repos:
+ name: Get repository list job
+ runs-on: ubuntu-latest
+ outputs:
+ repositories: ${{ steps.list.outputs.repositories }}
+
+ 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).
+ - name: Validate inputs step
+ env:
+ REPOSITORIES: ${{ inputs.repositories }}
+ MANIFEST: ${{ inputs.manifest }}
+ MANIFEST_JQ: ${{ inputs.manifest-jq }}
+ run: |
+ set -euo pipefail
+ if [ -n "$REPOSITORIES" ] && [ -n "$MANIFEST" ]; then
+ echo "::error::Pass either 'repositories' or 'manifest', not both." >&2
+ exit 1
+ fi
+ if { [ -n "$MANIFEST" ] && [ -z "$MANIFEST_JQ" ]; } || { [ -z "$MANIFEST" ] && [ -n "$MANIFEST_JQ" ]; }; then
+ echo "::error::'manifest' and 'manifest-jq' must be set together." >&2
+ exit 1
+ fi
+
+ - name: Checkout code step
+ # Only the manifest path needs the tree; the static + default paths don't check out.
+ if: ${{ inputs.repositories == '' && inputs.manifest != '' }}
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref || github.ref }}
+
+ - name: Resolve repository list step
+ id: list
+ env:
+ REPOSITORIES: ${{ inputs.repositories }}
+ MANIFEST: ${{ inputs.manifest }}
+ MANIFEST_JQ: ${{ inputs.manifest-jq }}
+ run: |
+ set -euo pipefail
+ # Inputs validated above: at most one of repositories / manifest is set, and manifest implies manifest-jq.
+ if [ -n "$REPOSITORIES" ]; then
+ echo "repositories=$REPOSITORIES" >> "$GITHUB_OUTPUT"
+ 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.
+ echo "repositories=[\"$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')\"]" >> "$GITHUB_OUTPUT"
+ fi
+
+ publish-readme:
+ name: Publish Docker Hub readme job
+ runs-on: ubuntu-latest
+ needs: get-repos
+ strategy:
+ matrix:
+ repository: ${{ fromJSON(needs.get-repos.outputs.repositories) }}
+
+ steps:
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ inputs.ref || github.ref }}
+
+ # Optional: pull a same-run artifact the transform depends on (e.g. version includes for an m4 render).
+ - name: Download transform artifact step
+ if: ${{ inputs.transform-artifact != '' }}
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ inputs.transform-artifact }}
+ path: ${{ runner.temp }}/transform
+
+ # Optional readme render (e.g. m4 with version includes) before pushing.
+ - name: Generate readme step
+ if: ${{ inputs.transform-run != '' }}
+ run: |
+ set -euo pipefail
+ ${{ inputs.transform-run }}
+
+ - name: Publish Docker Hub readme step
+ uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+ repository: ${{ matrix.repository }}
+ short-description: ${{ github.event.repository.description }}
+ readme-filepath: ${{ inputs.readme-filepath }}
diff --git a/catalog/snippets/workflows/run-codegen-pull-request-task.yml b/catalog/snippets/workflows/run-codegen-pull-request-task.yml
index 34a56640..beab7e94 100644
--- a/catalog/snippets/workflows/run-codegen-pull-request-task.yml
+++ b/catalog/snippets/workflows/run-codegen-pull-request-task.yml
@@ -1,81 +1,81 @@
-name: Run codegen and pull request task
-
-# Runs codegen against `main` and `develop` in parallel via a matrix and opens a PR against each base
-# (codegen-main -> main, codegen-develop -> develop), which the merge-bot auto-merges independently.
-
-on:
- workflow_call:
- secrets:
- CODEGEN_APP_CLIENT_ID:
- required: true
- CODEGEN_APP_PRIVATE_KEY:
- required: true
- NINJA_API_KEY:
- required: true
-
-jobs:
-
- codegen:
- name: Run ${{ matrix.target.ref }} codegen and pull request job
- runs-on: ubuntu-latest
- permissions:
- contents: write
- pull-requests: write
- strategy:
- # Each branch gets its own parallel codegen run + PR; one branch's failure doesn't affect the other.
- fail-fast: false
- matrix:
- target:
- - ref: main
- branch: codegen-main
- - ref: develop
- branch: codegen-develop
-
- steps:
-
- - name: Generate GitHub App token step
- # App token so the PR open fires `pull_request` workflow events (GITHUB_TOKEN opens don't).
- id: app-token
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
- with:
- client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
- private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
-
- - name: Setup .NET SDK step
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
- with:
- dotnet-version: 10.x
-
- - name: Checkout code step
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ matrix.target.ref }}
- token: ${{ steps.app-token.outputs.token }}
-
- - name: Run codegen step
- run: |
- set -euo pipefail
- dotnet run --project ./CodeGen/CodeGen.csproj -- \
- --codepath ./CodeGen \
- --apikey "${{ secrets.NINJA_API_KEY }}"
-
- - name: Format code step
- run: |
- set -euo pipefail
- dotnet tool restore
- dotnet csharpier format --log-level=debug .
- git status
-
- - name: Create pull request step
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
- id: cpr
- with:
- # App token: triggers pull_request workflow events and creates verified commits as the app.
- token: ${{ steps.app-token.outputs.token }}
- base: ${{ matrix.target.ref }}
- branch: ${{ matrix.target.branch }}
- title: 'Update codegen files'
- body: 'This PR updates the codegen files.'
- commit-message: 'Update codegen files'
- delete-branch: true
- sign-commits: true
+name: Run codegen and pull request task
+
+# Runs codegen against `main` and `develop` in parallel via a matrix and opens a PR against each base
+# (codegen-main -> main, codegen-develop -> develop), which the merge-bot auto-merges independently.
+
+on:
+ workflow_call:
+ secrets:
+ CODEGEN_APP_CLIENT_ID:
+ required: true
+ CODEGEN_APP_PRIVATE_KEY:
+ required: true
+ NINJA_API_KEY:
+ required: true
+
+jobs:
+
+ codegen:
+ name: Run ${{ matrix.target.ref }} codegen and pull request job
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ strategy:
+ # Each branch gets its own parallel codegen run + PR; one branch's failure doesn't affect the other.
+ fail-fast: false
+ matrix:
+ target:
+ - ref: main
+ branch: codegen-main
+ - ref: develop
+ branch: codegen-develop
+
+ steps:
+
+ - name: Generate GitHub App token step
+ # App token so the PR open fires `pull_request` workflow events (GITHUB_TOKEN opens don't).
+ id: app-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}
+ private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}
+
+ - name: Setup .NET SDK step
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ with:
+ dotnet-version: 10.x
+
+ - name: Checkout code step
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ matrix.target.ref }}
+ token: ${{ steps.app-token.outputs.token }}
+
+ - name: Run codegen step
+ run: |
+ set -euo pipefail
+ dotnet run --project ./CodeGen/CodeGen.csproj -- \
+ --codepath ./CodeGen \
+ --apikey "${{ secrets.NINJA_API_KEY }}"
+
+ - name: Format code step
+ run: |
+ set -euo pipefail
+ dotnet tool restore
+ dotnet csharpier format --log-level=debug .
+ git status
+
+ - name: Create pull request step
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+ id: cpr
+ with:
+ # App token: triggers pull_request workflow events and creates verified commits as the app.
+ token: ${{ steps.app-token.outputs.token }}
+ base: ${{ matrix.target.ref }}
+ branch: ${{ matrix.target.branch }}
+ title: 'Update codegen files'
+ body: 'This PR updates the codegen files.'
+ commit-message: 'Update codegen files'
+ delete-branch: true
+ sign-commits: true
diff --git a/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml
index be0a11c5..f22d91bc 100644
--- a/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml
+++ b/catalog/snippets/workflows/run-periodic-codegen-pull-request.yml
@@ -1,23 +1,23 @@
-name: Run daily CodeGen and Pull Request action
-
-on:
- workflow_dispatch:
- schedule:
- # Daily at 04:00 UTC, staggered after the weekly publish so the two don't start together on Mondays.
- - 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.
- group: ${{ github.workflow }}
- cancel-in-progress: true
-
-jobs:
-
- run-codegen:
- name: Run codegen and pull request job
- uses: ./.github/workflows/run-codegen-pull-request-task.yml
- secrets: inherit
- permissions:
- contents: write
- pull-requests: write
+name: Run daily CodeGen and Pull Request action
+
+on:
+ workflow_dispatch:
+ schedule:
+ # Daily at 04:00 UTC, staggered after the weekly publish so the two don't start together on Mondays.
+ - 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.
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
+jobs:
+
+ run-codegen:
+ name: Run codegen and pull request job
+ uses: ./.github/workflows/run-codegen-pull-request-task.yml
+ secrets: inherit
+ permissions:
+ contents: write
+ pull-requests: write
diff --git a/spec/project-types.json b/spec/project-types.json
index 24527eb2..2b560b0d 100644
--- a/spec/project-types.json
+++ b/spec/project-types.json
@@ -134,7 +134,7 @@
{ "id": "recurring.comments", "verdict": "letter", "assert": "Comments are concise, only the non-obvious, no prose narration, and do not grow on re-edit.", "intentRef": "AGENTS.md#comments" },
{ "id": "recurring.charset", "verdict": "letter", "assert": "ASCII only in agent-authored text: no em-dash (use a spaced hyphen), no smart quotes, no stray non-ASCII.", "intentRef": "AGENTS.md#character-set" },
{ "id": "recurring.spelling", "verdict": "letter", "assert": "US English spelling; the shared cspell.json sets language en-US (a bare en accepts British spellings too).", "intentRef": "CODESTYLE.md#markdown-and-spelling" },
- { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line = crlf default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path); a per-extension-only form lacking the global [*] default is a drift finding. Edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" }
+ { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line = crlf default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path), plus workflow YAML in .github/workflows/* enforced by editorconfig-checker in CI; a per-extension-only form lacking the global [*] default is a drift finding. Edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" }
]
},
"readme-structure": {