Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N> --json id --jq '.id')
BOT_ID=$(gh api graphql -f query='
{
repository(owner: "<owner>", name: "<repo>") {
pullRequest(number: <N>) {
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

Expand All @@ -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.

Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/build-datebadge-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
61 changes: 53 additions & 8 deletions .github/workflows/build-docker-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,35 @@ 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:

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
Expand All @@ -25,19 +47,30 @@ 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

- 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:
Expand All @@ -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 }}
Expand Down
49 changes: 44 additions & 5 deletions .github/workflows/build-executable-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -13,46 +32,66 @@ 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
runs-on: ubuntu-latest
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:
dotnet-version: 10.x

- 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 }} \
-property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \
-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
Expand All @@ -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

Expand All @@ -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
21 changes: 19 additions & 2 deletions .github/workflows/build-nugetlibrary-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -35,14 +48,16 @@ jobs:

- name: Checkout code step
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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 ${{ 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 }} \
Expand All @@ -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
Loading
Loading