From 29e8d5e269522a2f9437167a8546643056c2018e Mon Sep 17 00:00:00 2001 From: ptr727 Date: Mon, 6 Jul 2026 18:01:06 -0700 Subject: [PATCH] Extend line-ending governance to pattern files and catalog snippets Encode that non-executed pattern files stay CRLF: `.dockerignore` and `.gitignore` are consumed by Linux tools, but their parsers strip a trailing CR, so only an interpreted Dockerfile (a CR breaks RUN heredocs and line continuations) is LF (AGENTS.md). Pin the catalog snippet workflows to LF in `.editorconfig` and convert them, so a copied snippet lands compliant with the workflow-YAML-LF rule instead of needing conversion. Note the workflow-YAML LF pin in the spec's `recurring.eol` assertion, which #250 left listing only the shell, Dockerfile, and shebang-`.py` pins. Co-Authored-By: Claude Opus 4.8 (1M context) --- .editorconfig | 5 + AGENTS.md | 2 +- .../workflows/build-datebadge-task.yml | 58 +- .../snippets/workflows/build-docker-task.yml | 192 +++---- .../workflows/build-executable-task.yml | 212 ++++---- .../workflows/build-nugetlibrary-task.yml | 178 +++---- .../workflows/build-pypilibrary-task.yml | 242 ++++----- .../snippets/workflows/build-release-task.yml | 504 +++++++++--------- .../workflows/check-upstream-version-task.yml | 266 ++++----- .../snippets/workflows/get-version-task.yml | 120 ++--- .../workflows/publish-docker-readme-task.yml | 292 +++++----- .../run-codegen-pull-request-task.yml | 162 +++--- .../run-periodic-codegen-pull-request.yml | 46 +- spec/project-types.json | 2 +- 14 files changed, 1143 insertions(+), 1138 deletions(-) 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": {