diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 654f87f6..752a7172 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -49,6 +49,8 @@ Use this section for provider-specific mechanics. The expected review loop *cont 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). +> **The reviewer login differs by API — this is intentional, not a typo.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer` — **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]` — **with** the suffix. Each query below uses the correct form for its API; match the API, not a single spelling, when adapting them. + ```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`). @@ -94,9 +96,10 @@ gh pr view --json reviews --jq \ '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ | grep -q "$PR_HEAD" && echo "covered via formal review" -# 2. Issue comment — show the most recent Copilot comment for manual confirmation. +# 2. Issue comment — show the most recent Copilot comment for manual +# confirmation. This is the REST API, so the login carries the `[bot]` suffix. gh api repos///issues//comments --jq \ - '[.[] | select(.user.login=="copilot-pull-request-reviewer")] | last | {created_at, body: .body[:200]}' + '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' ``` Coverage is confirmed when (1) exits 0. For issue comments (path 2), body content is the only reliable signal — `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 9d15ba61..fd36a51e 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -100,7 +100,7 @@ jobs: 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) || '' }} + 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' }} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 46239edb..33b5a8dc 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -71,10 +71,15 @@ jobs: build-nugetlibrary: name: Build NuGet library job if: ${{ inputs.enable_nuget }} + needs: [get-version] uses: ./.github/workflows/build-nugetlibrary-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the exact commit get-version resolved (immutable), not the + # possibly-moving branch ref: the publisher passes a branch name, and a + # commit landing mid-run could otherwise build artifacts from a different + # commit than the one the release tag (also GitCommitId) points at. + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} # Conditional push to NuGet.org — never on a smoke build. push: ${{ inputs.nuget && !inputs.smoke }} @@ -86,29 +91,35 @@ jobs: build-pypilibrary: name: Build PyPI library job if: ${{ inputs.enable_pypi }} + needs: [get-version] uses: ./.github/workflows/build-pypilibrary-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the resolved commit (see build-nugetlibrary). + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} build-executable: name: Build executable job if: ${{ inputs.enable_executable }} + needs: [get-version] uses: ./.github/workflows/build-executable-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the resolved commit (see build-nugetlibrary). + 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] uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: - ref: ${{ inputs.ref }} + # Pin to the resolved commit (see build-nugetlibrary). + ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} # Conditional push to Docker Hub — never on a smoke build. @@ -125,10 +136,13 @@ jobs: steps: + # Check out the exact built commit (NBGV `GitCommitId`), not the + # possibly-moving `inputs.ref` branch, so the uploaded release files + # match the tag even if the branch advances mid-run. - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.get-version.outputs.GitCommitId }} - name: Download NuGet library build artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -159,7 +173,11 @@ jobs: 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)." + 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 @@ -174,8 +192,11 @@ jobs: # 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. + # Skip the no-op weekly republish when the tag already exists, but always + # allow a manual `workflow_dispatch` through so it can repair/refresh a + # partially-created release for the same tag. - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' }} + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: generate_release_notes: true diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 56254f38..6a3ac3da 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,14 +11,16 @@ on: # (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. +# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) +# share a single GLOBAL, ref-independent group so they serialize: they push +# both branches' shared Docker tags + caches and create GitHub releases for +# both 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. Non-publishing `push` runs (the two-phase default) get a +# unique per-run group so they don't queue behind — or delay — a real publish; +# they only execute the no-op `setup` job and skip everything else. concurrency: - group: ${{ github.workflow }} + group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} # 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 diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 95a85031..681b00a1 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -101,7 +101,11 @@ jobs: # docs-only PR) — unit tests still run. smoke-build: name: Smoke build changed targets job - needs: [changes] + # Also gate on unit-test: the smoke build includes a Docker image build, so + # don't spend it when unit tests are already failing. A failed unit-test + # leaves this job skipped (needs unsatisfied) and the aggregator blocks on + # the unit-test failure directly. + needs: [changes, unit-test] if: >- needs.changes.outputs.docker == 'true' || needs.changes.outputs.nuget == 'true' || diff --git a/README.md b/README.md index 43339482..e1816cad 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ Which to pick: two-phase suits projects whose consumers are **pushed** updates ( [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-release.yml?logo=github&label=Docker%20Build +[dockerbuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&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 @@ -550,7 +550,7 @@ Which to pick: two-phase suits projects whose consumers are **pushed** updates ( [prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?include_prereleases&filter=*-g*&label=GitHub%20Pre-Release&logo=github [pypi-link]: https://pypi.org/project/ptr727-projecttemplate-library/ [pypireleaseversion-shield]: https://img.shields.io/pypi/v/ptr727-projecttemplate-library?logo=pypi&label=PyPI%20Release -[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?logo=github&label=Releases%20Build +[releasebuildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ProjectTemplate/publish-release.yml?event=schedule&logo=github&label=Releases%20Build [releases-link]: https://github.com/ptr727/ProjectTemplate/releases [releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/ProjectTemplate?logo=github&label=GitHub%20Release