diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7b9e51b0..654f87f6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -47,15 +47,40 @@ Use this section for provider-specific mechanics. The expected review loop *cont ### Triggering and Polling -Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice — treat it as best-effort, not guaranteed. Request review explicitly through the GitHub PR UI (request `Copilot` as a reviewer) after every push. +Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice — treat it as best-effort, not guaranteed. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This now works reliably (it previously did not — a maintainer had to click "re-request review" in the UI; the agent can now drive the loop end-to-end without that hand-off). + +```sh +# 1. PR node id + the Copilot reviewer's bot node id (read from any existing +# Copilot review; the reviewer login is `copilot-pull-request-reviewer`). +PR_NODE=$(gh pr view --json id --jq '.id') +BOT_ID=$(gh api graphql -f query=' +{ + repository(owner: "", name: "") { + pullRequest(number: ) { + reviews(first: 50) { nodes { author { __typename login ... on Bot { id } } } } + } + } +}' --jq '[.data.repository.pullRequest.reviews.nodes[] + | select(.author.login == "copilot-pull-request-reviewer") + | .author.id] | first') + +# 2. Re-request a Copilot review on the current head. +gh api graphql -f query=' +mutation($pr: ID!, $bot: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { + pullRequest { id } + } +}' -F pr="$PR_NODE" -F bot="$BOT_ID" +``` + +The bot node id is read from an existing Copilot review, so step 1 needs at least one prior review on the PR — the auto-review-on-open normally supplies the first one. If no Copilot review exists yet and auto-review didn't fire, request `Copilot` once through the GitHub PR UI to seed it, then use the mutation for every subsequent re-request. **Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. -Known non-working request paths (don't rely on them): +Known non-working request paths (don't rely on them — use the `requestReviews` mutation above instead): - `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. - `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. -- GraphQL `requestReviews` rejects Copilot's bot node. ### Verify Review Covered Current Head @@ -81,7 +106,7 @@ Coverage is confirmed when (1) exits 0. For issue comments (path 2), body conten If a review did not run on the current head, retry: 1. Wait briefly and check head-SHA coverage (see above). -1. Request review again via the GitHub PR UI. +1. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"); fall back to the GitHub PR UI only if the mutation no-ops. 1. Retry up to two more times (three total). 1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index 471ec3cc..3e805f44 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -2,6 +2,14 @@ name: Build BYOB date badge task on: workflow_call: + inputs: + # Logical branch this badge run is for. The badge only updates on + # `main`; the publisher passes the branch explicitly so a scheduled + # run building `develop` doesn't try to write the main badge. Required + # (no `github.ref_name` fallback) so the gate can't silently misfire. + branch: + required: true + type: string jobs: @@ -16,7 +24,7 @@ jobs: run: echo "date=$(date)" >> $GITHUB_OUTPUT - name: Build BYOB date badge step - if: ${{ github.ref_name == 'main' }} + if: ${{ inputs.branch == 'main' }} uses: RubbaBoy/BYOB@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 with: name: lastbuild diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index e103c23f..9d15ba61 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -8,6 +8,26 @@ on: 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`, + # anything else => Debug/`develop`). Required (no `github.ref_name` + # fallback): the publisher builds develop from a run whose + # `github.ref_name` is `main`, so a silent fallback would mistag it. + # The orchestrator always passes it explicitly. + 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: @@ -15,6 +35,8 @@ jobs: 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 @@ -25,8 +47,13 @@ jobs: - name: Checkout step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + # QEMU only exists to emulate arm64. Smoke builds are amd64-only, so + # skip it entirely to save the emulation setup cost. - name: Setup QEMU step + if: ${{ !inputs.smoke }} uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 with: platforms: linux/amd64,linux/arm64 @@ -34,10 +61,16 @@ jobs: - name: Setup Buildx step uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 with: - platforms: linux/amd64,linux/arm64 + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Always login to Docker Hub, not just on push, to benefit from - # higher rate limits with a Docker subscription for pulls and cache + # Always login to Docker Hub, not just on push, to benefit from higher + # rate limits with a Docker subscription for pulls and cache reads on + # every build (including smoke). This is a CONSCIOUS choice over gating + # login on `inputs.push`: the trade-off is that fork PRs without access + # to the Docker Hub secrets cannot run the Docker smoke build. + # Acceptable here because the repo is private and PRs are same-repo; a + # public derived project that accepts fork PRs may prefer to gate this + # step on `inputs.push`. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: @@ -51,14 +84,26 @@ jobs: push: ${{ inputs.push }} file: ./Docker/Dockerfile tags: | - docker.io/ptr727/projecttemplate:${{ github.ref_name == 'main' && 'latest' || 'develop' }} + docker.io/ptr727/projecttemplate:${{ inputs.branch == 'main' && 'latest' || 'develop' }} docker.io/ptr727/projecttemplate:${{ needs.get-version.outputs.SemVer2 }} - platforms: linux/amd64,linux/arm64 - cache-from: type=registry,ref=docker.io/ptr727/projecttemplate:buildcache - cache-to: type=registry,ref=docker.io/ptr727/projecttemplate:buildcache,mode=max + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + # Branch-scoped registry cache. READ both branches' caches — the + # layers are nearly identical (only BUILD_CONFIGURATION differs), so + # a main build can seed from develop's cache and vice versa — but + # WRITE only this branch's own tag, and only when actually pushing. + # Gating the export on `inputs.push` (not just `!smoke`) means a + # non-publishing build never writes the shared registry cache or + # needs Docker Hub write creds — smoke builds (always push=false) are + # covered too. Branch-scoping is what lets the publisher's weekly + # matrix build main and develop concurrently in one run without the + # two legs overwriting a single shared cache (destroying hit rates). + 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', inputs.branch) || '' }} build-args: | LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }} - BUILD_CONFIGURATION=${{ github.ref_name == 'main' && 'Release' || 'Debug' }} + 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 }} diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index c9724930..4373869f 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -2,6 +2,25 @@ 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 `github.ref_name` fallback, which would mislabel + # the develop leg of the publisher's matrix); the orchestrator passes it. + branch: + required: true + type: string + # Smoke mode: build a representative runtime subset (linux-x64 + + # win-x64) instead of the full 7-runtime matrix, and skip the zip / + # artifact aggregation. Used for fast PR feedback. + smoke: + required: false + type: boolean + default: false outputs: # Output of the uploaded artifact id artifact-id: @@ -13,6 +32,8 @@ jobs: 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 @@ -20,10 +41,18 @@ jobs: needs: [get-version] strategy: matrix: - runtime: [ win-x64, linux-x64, linux-musl-x64, linux-arm, linux-arm64, osx-x64, osx-arm64 ] + runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }} steps: + # NOTE: NuGet restore caching is intentionally NOT enabled on the .NET + # jobs (this matrix, build-nugetlibrary-task, and the unit-test job). The + # restore is low-overhead for this template's small dependency set, and + # `setup-dotnet`'s built-in cache requires a `packages.lock.json` that + # Central Package Management (Directory.Packages.props) does not produce + # by default. The Docker layer cache and uv's cache (which carry the + # expensive work) are enabled; revisit .NET restore caching only if the + # dependency graph grows enough to make it worthwhile. - name: Setup .NET SDK step uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: @@ -31,13 +60,15 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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 ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ + --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ -property:PublishAot=false \ -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ @@ -45,14 +76,22 @@ jobs: -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} + # Artifact names are suffixed with the branch so the publisher can build + # `main` and `develop` in the same workflow run (a branch matrix) without + # two legs colliding on an identical artifact name. - name: Upload matrix build artifacts step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: publish-${{ matrix.runtime }} + name: publish-${{ inputs.branch }}-${{ matrix.runtime }} path: ${{ runner.temp }}/publish + # Smoke builds only need the per-runtime compile to succeed (fast PR + # feedback) — the zipped, downloadable artifact is a release concern, so + # skip the aggregation entirely on smoke. The `artifact-id` output is then + # empty, which is fine because the GitHub release job never runs on smoke. upload-build-artifacts: name: Upload matrix build artifacts job + if: ${{ !inputs.smoke }} outputs: artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} runs-on: ubuntu-latest @@ -63,7 +102,7 @@ jobs: - name: Download matrix build artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - pattern: publish-* + pattern: publish-${{ inputs.branch }}-* merge-multiple: true path: ${{ runner.temp }}/publish @@ -74,5 +113,5 @@ jobs: id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: executable-build + name: executable-build-${{ inputs.branch }} path: ${{ runner.temp }}/Console.7z diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index cfd39777..a05aa01e 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -8,6 +8,17 @@ on: 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 `github.ref_name` fallback, which would mislabel + # the develop leg of the publisher's matrix); the orchestrator passes it. + branch: + required: true + type: string outputs: # Output of the uploaded artifact id artifact-id: @@ -18,6 +29,8 @@ 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 @@ -35,6 +48,8 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Build NuGet library project step run: | @@ -42,7 +57,7 @@ jobs: dotnet build ./NuGetLibrary/NuGetLibrary.csproj \ -property:OutputPath=${{ runner.temp }}/publish/ \ -property:PackageOutputPath=${{ runner.temp }}/publish/ \ - --configuration ${{ github.ref_name == 'main' && 'Release' || 'Debug' }} \ + --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 }} \ @@ -61,9 +76,11 @@ jobs: - name: Zip output step run: 7z a -t7z ${{ runner.temp }}/NuGetLibrary.7z ${{ runner.temp }}/publish/* + # Branch-suffixed so the publisher's branch matrix can build both + # branches in one run without colliding on the artifact name. - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: nugetlibrary-build + name: nugetlibrary-build-${{ inputs.branch }} path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 7b92e201..d4f209be 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -5,12 +5,25 @@ name: Build PyPI library task # Publishing happens directly in `publish-release.yml` so that the # `id-token: write` permission required by Trusted Publishing is granted # at the entry-point job, not propagated through a reusable-workflow -# chain (which would require every caller — including `test-release-task.yml` -# during PR validation — to also grant id-token write, even when no +# chain (which would require every caller — including the PR smoke build in +# `test-pull-request.yml` — to also grant id-token write, even when no # publishing happens). 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 suffix, anything else => plain release). Required (no + # `github.ref_name` fallback, which would mislabel the develop leg of + # the publisher's matrix); the orchestrator passes it. + branch: + required: true + type: string outputs: artifact-name: value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} @@ -22,6 +35,8 @@ 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 @@ -31,13 +46,15 @@ jobs: run: working-directory: ./PyPiLibrary outputs: - artifact-name: pypilibrary-build + artifact-name: pypilibrary-build-${{ inputs.branch }} artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} steps: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Setup uv step uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 @@ -95,23 +112,24 @@ jobs: # the main release until a new develop # commit lands. This is accepted as a # small, self-healing gap. - # other refs (PR validation via test-release-task, feature + # other branches (PR smoke build via test-pull-request, feature # branches) -> AssemblyFileVersion as-is. These - # refs never publish; we just need a PEP 440 valid + # never publish; we just need a PEP 440 valid # string for `uv build`. - name: Compute PyPI version step id: pypiver run: | set -euo pipefail - if [[ "$GITHUB_REF" == "refs/heads/develop" ]]; then + if [[ "$BRANCH" == "develop" ]]; then version="${AFV}.dev0" else version="$AFV" fi - echo "PyPI version for $GITHUB_REF: $version" + echo "PyPI version for branch $BRANCH: $version" echo "version=$version" >> "$GITHUB_OUTPUT" env: AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }} + BRANCH: ${{ inputs.branch }} # Replace the `__version__` line in `_version.py` (which ships # hardcoded "0.0.0" so local `uv build` works without CI) with the @@ -141,9 +159,12 @@ jobs: - 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 on the artifact name. The + # publish-pypi job downloads `pypilibrary-build-`. - name: Upload build artifacts step id: artifact-upload-step uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: pypilibrary-build + name: pypilibrary-build-${{ inputs.branch }} path: PyPiLibrary/dist/* diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index cfe9b9ae..46239edb 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -18,6 +18,46 @@ on: 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 `github.ref_name` fallback): the publisher builds both + # `main` and `develop` from one run whose `github.ref_name` is `main`, + # so a silent fallback would mislabel the develop leg. Every caller + # passes it explicitly; a missing value should fail loudly. + branch: + required: true + type: string + # Smoke mode: reduced, never-published build for fast PR feedback. + # Forwarded to every target; also 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 derived + # project that drops a target deletes its job below and removes it from + # `github-release`'s `needs`; 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 jobs: @@ -25,14 +65,19 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} build-nugetlibrary: name: Build NuGet library job + if: ${{ inputs.enable_nuget }} uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: - # Conditional push to NuGet.org - push: ${{ inputs.nuget }} + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + # Conditional push to NuGet.org — never on a smoke build. + push: ${{ inputs.nuget && !inputs.smoke }} # PyPI publishing happens in `publish-release.yml`, not here, so that # `id-token: write` only needs to be granted at the entry-point job. @@ -40,25 +85,41 @@ jobs: # publish-release workflow downloads it by name in a sibling job. build-pypilibrary: name: Build PyPI library job + if: ${{ inputs.enable_pypi }} uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} build-executable: name: Build executable job + if: ${{ inputs.enable_executable }} uses: ./.github/workflows/build-executable-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} build-docker: name: Build Docker job + if: ${{ inputs.enable_docker }} uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: - # Conditional push to Docker Hub - push: ${{ inputs.dockerhub }} + ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} + # Conditional push to Docker Hub — never on a smoke build. + push: ${{ inputs.dockerhub && !inputs.smoke }} github-release: name: Publish GitHub release job - if: ${{ inputs.github }} + # `&& !inputs.smoke` enforces the "smoke never publishes" guarantee at the + # job level too (matching the `&& !inputs.smoke` push gates above), 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, build-nugetlibrary, build-pypilibrary, build-executable, build-docker] @@ -66,6 +127,8 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Download NuGet library build artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -79,19 +142,46 @@ jobs: artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} path: ./Publish + # The weekly publisher re-runs even when a branch has no new commits, so + # NBGV can produce a SemVer2 that was already released. GitHub release + # creation has no built-in skip-duplicate (unlike NuGet's + # `--skip-duplicate` and PyPI's `skip-existing`), and re-publishing an + # unchanged version is exactly the churn the two-phase model avoids — so + # skip the release step when a release for this tag already exists. The + # Docker mutable tags (`latest`/`develop`) and base-image refresh still + # happen regardless, so security-only rebuilds still ship. + - 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" + echo "Release $TAG already exists; skipping release creation (no-op republish)." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + # `target_commitish` MUST be set explicitly: softprops doesn't pass a # default through, and GitHub's REST API then defaults the new tag to - # the repository's default branch (main). On `push: develop` runs the - # tag would land on main's tip instead of the develop commit that - # built the artifact, leaving "Browse files" and `git checkout ` - # pointing at unrelated code. + # the repository's default branch (main). We pin it to NBGV's + # `GitCommitId` — the exact commit the version was computed from. This + # avoids two bugs: `github.sha` would be wrong (the publisher's branch + # matrix builds `develop` from a run whose `github.sha` is main's tip), + # and `inputs.branch` would be a moving ref (a commit landing mid-run + # could tag the release on a newer commit than the one that was built). + # The exact SHA is immutable, on the right branch, and consistent with + # both the SemVer2 tag and the uploaded artifacts. - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ github.sha }} - prerelease: ${{ github.ref_name != 'main' }} + target_commitish: ${{ needs.get-version.outputs.GitCommitId }} + prerelease: ${{ inputs.branch != 'main' }} files: | LICENSE README.md diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 24e1a7ca..e64bd474 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -2,6 +2,16 @@ name: Get version information task on: workflow_call: + inputs: + # Git ref to check out and version. Empty string falls back to the + # caller's default checkout ref (`github.ref`), preserving the + # original behavior. The publisher passes an explicit branch so a + # scheduled run — which always reports `github.ref` as the default + # branch — can still compute NBGV versions for `develop` too. + ref: + required: false + type: string + default: '' outputs: # Version information outputs SemVer2: @@ -12,6 +22,11 @@ on: value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} + # Full SHA of the commit NBGV computed the version from. Used to pin the + # GitHub release tag to the exact built commit (immutable), rather than a + # moving branch ref. + GitCommitId: + value: ${{ jobs.get-version.outputs.GitCommitId }} jobs: @@ -23,6 +38,7 @@ jobs: AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} + GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} steps: @@ -34,6 +50,7 @@ jobs: - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ inputs.ref }} fetch-depth: 0 # `dotnet/nbgv` is intentionally floated on `master` rather than diff --git a/.github/workflows/publish-periodic-docker-release.yml b/.github/workflows/publish-periodic-docker-release.yml deleted file mode 100644 index a264c592..00000000 --- a/.github/workflows/publish-periodic-docker-release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Publish weekly Docker image to Docker Hub action - -on: - workflow_dispatch: - schedule: - # Run weekly on Mondays at 02:00 UTC - - cron: '0 2 * * MON' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - build-docker: - name: Build Docker image job - uses: ./.github/workflows/build-docker-task.yml - secrets: inherit - with: - # Push to registry - push: true - - date-badge: - name: Create BYOB date badge job - needs: [build-docker] - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 1821d717..56254f38 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,84 +1,158 @@ -name: Publish project release action - -on: - push: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - create-release: - name: Publish project release job - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - permissions: - contents: write - with: - # Push to GitHub and NuGet and Docker Hub - github: true - nuget: true - dockerhub: true - - publish-pypi: - name: Publish PyPI library job - # Runs on pushes to both `main` and `develop`. `build-pypilibrary-task.yml` - # picks the PEP 440 version per branch (`M.N.P.B` release on main, - # `M.N.P.B.dev0` on develop — BuildNumber stays in the release segment - # so develop's release segment grows past main's per commit). Default - # `pip install ` filters `.dev0` and picks the main release; - # `pip install --pre ` includes dev releases and picks develop's - # higher release segment. Matches how NuGet/Docker tag develop builds - # as prerelease via NBGV `SemVer2`. - # The `pypi` GitHub environment's Deployment branch rule - # (Settings → Environments → pypi) restricts uploads to `main` + - # `develop` as defense in depth — see PyPiLibrary/README.md. - needs: [create-release] - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/project/ptr727-projecttemplate-library/ - # When a `permissions:` block is present, every scope not listed - # collapses to `none`. The job needs three things explicitly: - # - `id-token: write` for Trusted Publishing's OIDC exchange - # (pypa/gh-action-pypi-publish swaps the token for a short-lived - # PyPI upload token; no PYPI_API_TOKEN secret involved). - # - `contents: read` so `actions/checkout`-style operations and any - # repo metadata reads continue to work. - # - `actions: read` so `actions/download-artifact` can list and - # fetch the artifact uploaded by the build workflow earlier in - # the same run. - permissions: - id-token: write - contents: read - actions: read - - steps: - - - name: Download PyPI library build artifacts step - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: pypilibrary-build - path: ./dist - - - name: Publish to PyPI step - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - packages-dir: ./dist - # Skip rather than fail when the version already exists on PyPI. - # The template ships with `__version__ = "0.0.0"` as a placeholder - # — the release-on-every-push model would otherwise re-upload the - # same version and fail the workflow until the adopter wires a - # real version scheme (see PyPiLibrary/README.md). - skip-existing: true - - date-badge: - name: Create BYOB date badge job - needs: [create-release] - uses: ./.github/workflows/build-datebadge-task.yml - secrets: inherit - permissions: - contents: write +name: Publish project release action + +on: + push: + branches: [ main, develop ] + workflow_dispatch: + schedule: + # Weekly full build/publish of both branches on Mondays at 02:00 UTC. + # This is the guaranteed publisher in the default two-phase model: routine + # merges only smoke-test, and this scheduled run republishes everything + # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). + - cron: '0 2 * * MON' + +# Single GLOBAL group (not ref-scoped): this workflow publishes shared, +# ref-independent artifacts — it pushes both branches' Docker tags + caches +# and creates GitHub releases for both — on schedule/dispatch regardless of +# the triggering ref. A ref-scoped group would let a scheduled run (ref=main) +# and a manual dispatch (ref=develop) run concurrently and double-push. The +# global group serializes every publish run. +concurrency: + group: ${{ github.workflow }} + # Documented exception to the standard `cancel-in-progress: true` (see + # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can + # leave a partially pushed multi-arch tag set or a half-created GitHub + # release. Queue instead of cancel so each publish runs to completion. + cancel-in-progress: false + +jobs: + + # Decide WHICH branches to publish and WHETHER to publish at all: + # - push -> publish only the pushed branch, and only when the + # `PUBLISH_ON_MERGE` repository variable is `true` + # (opt-in legacy continuous-release). Unset/false => the + # default two-phase model: merges don't publish. + # - schedule -> always publish BOTH branches (the weekly full build). + # - dispatch -> always publish BOTH branches (manual on-demand publish). + setup: + name: Resolve publish plan job + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.plan.outputs.branches }} + publish: ${{ steps.plan.outputs.publish }} + steps: + - name: Compute publish plan step + id: plan + env: + # Repository variable (Settings -> Actions -> Variables). Unset reads + # as empty string, so the default is the two-phase model. + PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} + run: | + set -euo pipefail + case "${{ github.event_name }}" in + push) + branches='["${{ github.ref_name }}"]' + if [[ "${PUBLISH_ON_MERGE:-}" == "true" ]]; then + publish=true + else + publish=false + fi + ;; + *) + # schedule / workflow_dispatch + branches='["main","develop"]' + publish=true + ;; + esac + echo "Event=${{ github.event_name }} branches=$branches publish=$publish" + echo "branches=$branches" >> "$GITHUB_OUTPUT" + echo "publish=$publish" >> "$GITHUB_OUTPUT" + + # Full build + publish of every target for each planned branch. The branch + # matrix lets a single scheduled run publish both `main` (Release/`latest`, + # non-prerelease) and `develop` (Debug/`develop`, prerelease) — each leg + # checks out and versions its own branch via the threaded `ref`/`branch`. + publish: + name: Publish project release job + needs: [setup] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + permissions: + contents: write + with: + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub and NuGet and Docker Hub. + github: true + nuget: true + dockerhub: true + + publish-pypi: + name: Publish PyPI library job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + runs-on: ubuntu-latest + # `build-pypilibrary-task.yml` picks the PEP 440 version per branch + # (`M.N.P.B` release on main, `M.N.P.B.dev0` on develop). Default + # `pip install ` filters `.dev0` and picks the main release; + # `pip install --pre ` includes dev releases. The `pypi` GitHub + # environment's Deployment branch rule restricts uploads to `main` + + # `develop` as defense in depth — see PyPiLibrary/README.md. + environment: + name: pypi + url: https://pypi.org/project/ptr727-projecttemplate-library/ + # When a `permissions:` block is present, every scope not listed collapses + # to `none`. The job needs three things explicitly: + # - `id-token: write` for Trusted Publishing's OIDC exchange + # (pypa/gh-action-pypi-publish swaps the token for a short-lived PyPI + # upload token; no PYPI_API_TOKEN secret involved). + # - `contents: read` for repo metadata reads. + # - `actions: read` so `actions/download-artifact` can fetch the artifact + # uploaded by the build job earlier in the same run. + permissions: + id-token: write + contents: read + actions: read + + steps: + + - name: Download PyPI library build artifacts step + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + # Branch-suffixed name uploaded by build-pypilibrary-task.yml so both + # branch legs in this run stay distinct. + name: pypilibrary-build-${{ matrix.branch }} + path: ./dist + + - name: Publish to PyPI step + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: ./dist + # Skip rather than fail when the version already exists on PyPI — the + # weekly republish re-uploads the same version when nothing changed. + skip-existing: true + + date-badge: + name: Create BYOB date badge job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-datebadge-task.yml + secrets: inherit + permissions: + contents: write + with: + # The badge task self-gates to `main`; the develop leg is a no-op. + branch: ${{ matrix.branch }} diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index 8508744a..447b4178 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -1,10 +1,15 @@ -name: Run weekly CodeGen and Pull Request action +name: Run daily CodeGen and Pull Request action on: workflow_dispatch: schedule: - # Run weekly on Mondays at 02:00 UTC. - - cron: '0 2 * * MON' + # Run daily at 04:00 UTC. Staggered two hours after the weekly publish + # (`publish-release.yml`, Mondays 02:00) so the two don't start together + # on Mondays. Codegen merges are cheap in the default two-phase model + # (they only smoke-test, the weekly publish batches the actual release), + # so running daily keeps both branches' generated content fresh without + # triggering a build per merge. + - cron: '0 4 * * *' concurrency: # Standard AGENTS.md "Concurrency" convention. Scheduled runs always diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 53e1d628..95a85031 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,37 +1,161 @@ -name: Test pull request action - -on: - pull_request: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - test-release: - name: Test release job - uses: ./.github/workflows/test-release-task.yml - secrets: inherit - - # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs - # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 - check-workflow-status: - name: Check pull request workflow status - runs-on: ubuntu-latest - needs: - [ test-release ] - if: always() - steps: - - name: Check workflow results step - run: | - set -euo pipefail - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." - exit 1 - fi - } - exit_on_result "test-release" "${{ needs.test-release.result }}" +name: Test pull request action + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # Detect which delivery targets a PR actually touches so we only smoke-build + # what changed. Build-workflow files are intentionally NOT in any filter: a + # path filter can't tell a logic change in a build workflow from an action- + # version bump. A workflow-only change is therefore not smoke-built — the + # reusable workflows are exercised instead by the next run that uses them (a + # later code PR's smoke build, or the scheduled/publish run); lint workflow + # edits with `actionlint` locally before pushing (there is no CI lint job). + # On `workflow_dispatch` (no PR base to diff against) every target is forced + # on so a manual run is a full smoke build. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + # `dorny/paths-filter` lists the PR's changed files via the GitHub API + # (this job does not check out the tree), which needs `pull-requests: read`. + # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. + permissions: + contents: read + pull-requests: read + outputs: + docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} + nuget: ${{ github.event_name == 'pull_request' && steps.filter.outputs.nuget || 'true' }} + pypi: ${{ github.event_name == 'pull_request' && steps.filter.outputs.pypi || 'true' }} + executable: ${{ github.event_name == 'pull_request' && steps.filter.outputs.executable || 'true' }} + steps: + - name: Filter changed paths step + id: filter + if: ${{ github.event_name == 'pull_request' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + shared: &shared + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'version.json' + - '*.slnx' + docker: + - *shared + - 'Docker/**' + - 'Console/**' + - 'NuGetLibrary/**' + executable: + - *shared + - 'Console/**' + - 'NuGetLibrary/**' + nuget: + - *shared + - 'NuGetLibrary/**' + pypi: + - 'PyPiLibrary/**' + # The PyPI package version comes from NBGV reading version.json, + # so a version bump should rebuild it. The other shared files + # (Directory.*.props, *.slnx) are .NET-only and don't affect the + # Python build, so they're intentionally excluded here. + - 'version.json' + + # Unit tests are cheap and validate the shared C# code, so they always run + # regardless of which targets changed. + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore .NET local tools step + run: dotnet tool restore + + - name: Check formatting with CSharpier step + run: dotnet csharpier check . + + - name: Verify .NET style with dotnet format step + run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + + - name: Run unit tests step + run: dotnet test + + # Fast PR feedback: build only the changed targets, in smoke mode (Docker + # amd64-only, reduced executable matrix, no publishing). Validates the PR's + # base-branch configuration (Release for main, Debug for develop) by passing + # `branch: github.base_ref`. Skipped entirely when no target changed (e.g. a + # docs-only PR) — unit tests still run. + smoke-build: + name: Smoke build changed targets job + needs: [changes] + if: >- + needs.changes.outputs.docker == 'true' || + needs.changes.outputs.nuget == 'true' || + needs.changes.outputs.pypi == 'true' || + needs.changes.outputs.executable == 'true' + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + with: + smoke: true + # Do not publish anything from a PR. + github: false + nuget: false + dockerhub: false + # Check out the PR head by SHA (not head_ref): the head SHA is reachable + # in the base repo via refs/pull/N/head even for fork PRs, whereas the + # head_ref branch name does not exist in the base repo for forks and + # would fail checkout. Validate it in the base branch's configuration. + # `workflow_dispatch` has no pull_request payload, so fall back to the + # triggering ref. + ref: ${{ github.event.pull_request.head.sha || github.ref_name }} + branch: ${{ github.base_ref || github.ref_name }} + enable_docker: ${{ needs.changes.outputs.docker == 'true' }} + enable_nuget: ${{ needs.changes.outputs.nuget == 'true' }} + enable_pypi: ${{ needs.changes.outputs.pypi == 'true' }} + enable_executable: ${{ needs.changes.outputs.executable == 'true' }} + + # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs + # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 + # This job's name is bound to the branch ruleset as the required status check + # context — do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). + check-workflow-status: + name: Check pull request workflow status + runs-on: ubuntu-latest + needs: + [ changes, unit-test, smoke-build ] + if: always() + steps: + - name: Check workflow results step + run: | + set -euo pipefail + exit_on_result() { + if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then + echo "Job '$1' failed or was cancelled." + exit 1 + fi + } + # The paths-filter job MUST succeed: if it failed we don't know which + # targets changed, so a target-changing PR could merge with its smoke + # build silently skipped. Treat anything other than success as a block. + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + exit 1 + fi + # unit-test always runs; smoke-build may be legitimately skipped + # (no target changed) — `skipped` passes, only failure/cancelled blocks. + exit_on_result "unit-test" "${{ needs.unit-test.result }}" + exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml deleted file mode 100644 index bc36044d..00000000 --- a/.github/workflows/test-release-task.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Test release task - -on: - workflow_call: - workflow_dispatch: - -jobs: - - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Restore .NET local tools step - run: dotnet tool restore - - - name: Check formatting with CSharpier step - run: dotnet csharpier check . - - - name: Verify .NET style with dotnet format step - run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - - - name: Run unit tests step - run: dotnet test - - build-release: - name: Build release without publishing job - needs: [unit-test] - uses: ./.github/workflows/build-release-task.yml - secrets: inherit - with: - # Do not publish - github: false - nuget: false - dockerhub: false diff --git a/AGENTS.md b/AGENTS.md index 4f7ffd17..5098197b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Treat this file as authoritative for everything else; don't restate its rules el ## Branching Model - `develop` is the integration branch. Feature branches → `develop` is **squash-only**; develop is kept linear. -- `develop` → `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which is what makes the "release on every push" model attribute releases to the develop commits that produced them. Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. +- `develop` → `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which lets the release model attribute releases to the develop commits that produced them (relevant both for the weekly publish and the opt-in `PUBLISH_ON_MERGE` mode — see "Release Model" below). Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. - All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. - **`develop` is forward-only — no `main → develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` (`b9b0447`, `410ba56`, `ffb9e64`, `5ce95cf`, etc.) predate this rule and must not be repeated. - **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. @@ -30,6 +30,16 @@ Treat this file as authoritative for everything else; don't restate its rules el - **Dual-target codegen + per-run state = merge conflicts.** If a generator embeds per-invocation state (timestamps, GUIDs, build IDs) and runs independently on `main` and `develop`, the two branches' outputs diverge and every `develop → main` release conflicts on the generated file. This template's `CodeGen/CodeGen.cs` demo embeds a timestamp; the codegen workflow passes `--runtime "${{ github.run_started_at }}"` to both matrix legs so they produce byte-identical output. **That `--runtime` plumbing is template hygiene only — not a codegen pattern derived projects should reproduce.** Your real generators should either be deterministic given the same inputs (preferred), or not run on both release branches simultaneously, or absorb the per-release merge cost. - **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the template uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form — do not reintroduce `app-id` / `CODEGEN_APP_ID`. See [README "Template - GitHub Setup"](./README.md#template---github-setup) for the secret-setup procedure. +## Release Model + +The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: + +- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. Build-workflow files are intentionally not in the path filters — a filter can't tell a logic change from an action-version bump — so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. +- **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase. +- **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") — a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. +- **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease — `main` ⇒ Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** — the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. +- **Per-target subsetting (derived projects).** `build-release-task.yml` has per-target `enable_*` gates and self-contained leaf tasks, so a project that drops a target deletes: its `build--task.yml`, the matching job + `github-release` `needs` entry in `build-release-task.yml`, its path-filter entry in `test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in `publish-release.yml`. CodeGen, versioning, badge, merge-bot, and Dependabot are target-agnostic. + ## Pull Request Title and Commit Message Conventions ### Format @@ -74,14 +84,14 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom ### Expected Review Loop 1. Push changes to the PR branch. -2. Confirm a review was requested for the **current head SHA** (auto-trigger is unreliable; request explicitly). +2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (now reliable end-to-end — see the runbook); the UI is only a fallback. 3. Wait for review activity on that head. 4. Triage findings. 5. Apply fixes or write a rationale for declines. 6. Reply to each thread and resolve what was addressed. 7. Re-run the loop after every fix push until no actionable findings remain. -`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Merge only after review on the latest head SHA is confirmed and actionable findings are closed. +`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Drive the loop to green — review confirmed on the latest head SHA and every actionable finding closed — and then **wait for the maintainer's explicit permission to merge**. The agent does not merge on its own (consistent with "default to staging"; merging is maintainer-authorized). For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract; that file owns the mechanics. @@ -119,13 +129,13 @@ These conventions describe the target state. New and modified workflows must res - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do — `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim — renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exception**: [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order. Cancellation would leave auto-merge in an inconsistent state. The rationale is recorded inline in that workflow's header comment. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order — cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms — `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. - **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies — `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish: ${{ github.sha }}` explicitly. Without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. +- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly — without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix — a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). ## Devcontainer diff --git a/ProjectTemplate.slnx b/ProjectTemplate.slnx index e751e769..ceffba5b 100644 --- a/ProjectTemplate.slnx +++ b/ProjectTemplate.slnx @@ -8,12 +8,10 @@ - - diff --git a/README.md b/README.md index bac4755f..43339482 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ Licensed under the [MIT License][license-link]\ - GitHub project security Settings / Secrets / Actions — for the codegen workflow and the codegen merge job. - GitHub project security Settings / Secrets / Dependabot — **required** because Dependabot-triggered `pull_request` workflow runs use a separate, restricted secret context that doesn't see Actions secrets. Without the App secrets in the Dependabot store, the `merge-dependabot` job in `merge-bot-pull-request.yml` can't mint an App token and the PR will never auto-merge. - If the codegen workflows require additional secrets (e.g. third-party API keys), register them in the Actions store; if a Dependabot-triggered workflow ever needs them, register them in the Dependabot store too. - - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly — unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This is why `publish-release.yml` fires on the merge commit after Dependabot or codegen auto-merge, and why the codegen workflow no longer needs the legacy close/reopen dance to trigger auto-merge. + - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly — unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This matters for two reasons: bot-opened PRs trigger the `test-pull-request.yml` smoke build (so they can't auto-merge unvalidated), and — when `PUBLISH_ON_MERGE` is enabled — the merge commit triggers `publish-release.yml`. It also means the codegen workflow no longer needs the legacy close/reopen dance to trigger auto-merge. - The codegen auto-merge condition in `merge-bot-pull-request.yml` (`merge-codegen` job) requires: - **Event is `opened` or `reopened`** — auto-merge is enabled once per PR at open time; subsequent `synchronize` events do not re-enable. This is what lets the `disable-auto-merge-on-maintainer-push` safeguard (below) stick. - `github.event.pull_request.user.login == 'ptr727-codegen[bot]'` — PR was opened by the App. @@ -465,7 +465,7 @@ Licensed under the [MIT License][license-link]\ **Codegen workflow schedule**: -- `run-periodic-codegen-pull-request.yml` runs every **Monday** at 02:00 UTC, plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. +- `run-periodic-codegen-pull-request.yml` runs **daily** at 04:00 UTC (staggered two hours after the weekly publish), plus on-demand via `workflow_dispatch`. It uses the App token (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY`) to commit, open the PR as `ptr727-codegen[bot]`, and let the merge-bot auto-merge once CI passes. No PAT, no close/reopen dance. Daily is cheap in the default two-phase model — codegen merges only smoke-test; the weekly publish batches the actual release. **GitHub project settings**: @@ -514,32 +514,21 @@ See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for the authorita - `develop` → `main`: **merge-commit** (preserves develop's commit list as a real second-parent reference on main; main ruleset enforces this). - **`develop` is forward-only.** No `main → develop` back-merges. The develop squash-only ruleset physically blocks merge commits. - **Bots open parallel PRs against both branches.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates each ecosystem entry per branch, and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix (branch names `codegen-main` and `codegen-develop`). Each branch absorbs its own bot PRs independently — neither falls behind, no back-merges needed. +- **Review-then-merge loop.** Every PR is reviewed by GitHub Copilot. The agent pushes, re-requests a review on the new head (now reliable via the `requestReviews` GraphQL mutation), addresses and resolves each finding, repeats until green, and then **waits for the maintainer's explicit permission to merge** — it does not self-merge. See [AGENTS.md "PR Review Etiquette"](./AGENTS.md#pr-review-etiquette) and the [Copilot Review Runbook](./.github/copilot-instructions.md#github-copilot-review-runbook) for the mechanics. -### Template - Release Distribution Model: Push vs. Pull +### Template - Release Distribution Model: Two-Phase by Default -This template ships with a **push-on-merge release model** — every commit on `main` triggers [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) which publishes a GitHub release, NuGet/PyPI uploads, Docker tags, and platform executables. With the dual-target bot model (Dependabot/codegen targeting both branches), this means every Dependabot bump that lands on `main` produces a new release. That's the right default for projects whose consumers **pull** at their own cadence (Docker pulls, NuGet/PyPI installs, manual binary downloads) — releases are cheap and frequent, consumers update on their own schedule. +This template ships with a **two-phase model** that decouples merging from publishing: -For projects whose consumers are **pushed** updates (HACS for Home Assistant, package managers that auto-update integrations, Linux distros that vendor from `main`), every release is a forced update to all users. Frequent bot-driven releases become noise. To switch to a **manual main-release model** while keeping the rest of the dual-target dual-channel flow: +- **Pull requests smoke-test only.** [`.github/workflows/test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then path-gates a **reduced** build of only the targets a PR touches (`dorny/paths-filter`): Docker as `linux/amd64` only (no QEMU/arm64), the executable as a representative runtime subset, and nothing is pushed. A docs-only PR runs unit tests alone; a Dependabot github-actions bump is unit-tests-only. This is fast feedback, not a release. +- **Merges to `main`/`develop` do not publish.** A push only smoke-tested the PR; merging it republishes nothing. +- **The weekly schedule + manual dispatch are the sole publishers.** [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) runs every **Monday 02:00 UTC** and on-demand via `workflow_dispatch`, and on either trigger does the **full** build/publish of **both** `main` (Release / `latest` / non-prerelease) and `develop` (Debug / `develop` / prerelease) — GitHub release, NuGet/PyPI uploads, multi-arch Docker tags, platform executables, and a refreshed Docker base image. Trigger a release on demand from the Actions UI when you want one between weekly runs. -1. Edit [`.github/workflows/publish-release.yml`](./.github/workflows/publish-release.yml) and change the trigger: +This batches cheap bot churn (Dependabot/codegen merge daily, validated by smoke builds) into one periodic publish instead of one release per merge, and keeps PR feedback fast by deferring the slow `arm64`/full-matrix builds to the publisher. - ```diff - on: - - push: - - branches: [ main, develop ] - - workflow_dispatch: - + push: - + branches: [ develop ] - + workflow_dispatch: - ``` - - Result: `develop` pushes still publish dev releases automatically (PEP 440 `.dev0` to PyPI, NBGV-prerelease tags on NuGet, prerelease GitHub releases). `main` pushes no longer auto-publish; you trigger the release manually via the GitHub Actions UI (`workflow_dispatch`) when a real release is wanted. - -2. **(Optional)** narrow what flows into `main` automatically. If a sea of Dependabot PRs on `main` is noisy without auto-release, either: - - Drop the `main`-target Dependabot entries from `.github/dependabot.yml` (so deps update on `develop` only, and reach `main` through the next develop → main release the maintainer triggers — closer to a pure develop-only flow with manual cadence), or - - Keep dual-target Dependabot and let the merge-bot auto-merge them silently into `main`; main always has fresh code, but ships only when the maintainer dispatches a release. +**Opt in to publish-on-merge.** Set the repository variable `PUBLISH_ON_MERGE` to `true` (Settings → Secrets and variables → Actions → Variables) to restore the legacy **continuous-release** model: every push/merge to `main` publishes `main` and every push to `develop` publishes `develop`, immediately. The weekly + manual publishers still run. Leave the variable unset (or `false`) for the two-phase default. It's a repository variable, not a workflow edit, so pulling template updates never conflicts with your choice. -For an example of the manual-release model in production, see [homeassistant-purpleair](https://github.com/ptr727/homeassistant-purpleair) — that integration ships through HACS (push distribution) and uses `workflow_dispatch` for actual releases. +Which to pick: two-phase suits projects whose consumers are **pushed** updates (HACS for Home Assistant, package managers that auto-update, Linux distros that vendor from `main`) where every release is a forced update and frequent bot-driven releases are noise. `PUBLISH_ON_MERGE=true` suits projects whose consumers **pull** at their own cadence (Docker pulls, NuGet/PyPI installs, manual downloads) and want every merged change available immediately. For an example of a push-distribution project, see [homeassistant-purpleair](https://github.com/ptr727/homeassistant-purpleair) (ships through HACS). @@ -547,7 +536,7 @@ For an example of the manual-release model in production, see [homeassistant-pur [commits-link]: https://github.com/ptr727/ProjectTemplate/commits/main [discussions-link]: https://github.com/ptr727/ProjectTemplate/discussions [docker-link]: https://hub.docker.com/r/ptr727/projecttemplate -[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-periodic-docker-release.yml?logo=github&label=Docker%20Build +[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?logo=github&label=Docker%20Build [dockerdevelopversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/develop?label=Docker%20Develop&logo=docker&color=orange [dockerlatestversion-shield]: https://img.shields.io/docker/v/ptr727/projecttemplate/latest?label=Docker%20Latest&logo=docker [github-link]: https://github.com/ptr727/ProjectTemplate