From d611355d99234fcfadbdc2d3932ead61ca9f25d1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 14:54:54 -0700 Subject: [PATCH 1/2] Adopt the Hub-Hosted Validate and Release Chain Tasks Replace validate-task.yml, build-release-task.yml, build-executable-task.yml, build-docker-task.yml, and get-version-task.yml with caller stubs reaching ptr727/ProjectTemplate's hub-hosted reusable workflows, pinned to the 2.0.352 release, per docs/reusable-workflows.md "Adopting the Gates" and "Adopting the Release Chain". test-pull-request.yml moves from a push-all-branches trigger to the hub's release-with-smoke shape (pull_request plus a paths filter). publish-release.yml gains a plan job from publish-plan-task.yml, keeping the existing dispatch-plus-schedule trigger and publish behavior. No repo-specific hook is needed: the vanilla executable and Docker layout matches the hub defaults through inputs alone. Updates the OPERATIONS.md sentence describing the deleted validate-task.yml as hub-owned. --- .github/workflows/build-docker-task.yml | 113 ---------- .github/workflows/build-executable-task.yml | 107 ---------- .github/workflows/build-release-task.yml | 225 -------------------- .github/workflows/get-version-task.yml | 56 ----- .github/workflows/publish-release.yml | 47 +++- .github/workflows/test-pull-request.yml | 86 +++++--- .github/workflows/validate-task.yml | 121 ----------- OPERATIONS.md | 2 +- 8 files changed, 98 insertions(+), 659 deletions(-) delete mode 100644 .github/workflows/build-docker-task.yml delete mode 100644 .github/workflows/build-executable-task.yml delete mode 100644 .github/workflows/build-release-task.yml delete mode 100644 .github/workflows/get-version-task.yml delete mode 100644 .github/workflows/validate-task.yml diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml deleted file mode 100644 index d95fdb3..0000000 --- a/.github/workflows/build-docker-task.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Build Docker image task - -# Builds the image (multi-arch on main, amd64 on develop/smoke) and, on a main publish, pushes it plus the Docker Hub overview. -# Branch drives config and tags: main => Release/`latest`, else Debug/`develop`, plus the `:SemVer2` tag. Smoke builds -# amd64 only and never pushes; registry buildcache is branch-scoped. The orchestrator passes `branch` explicitly -# (the publisher builds one branch per run - the trigger ref - and smoke passes github.ref_name). -on: - workflow_call: - inputs: - # Push the built image and the Docker Hub overview. - push: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch: main => Release/`latest`, else Debug/`develop`. Required (see header). - branch: - required: true - type: string - # Smoke: build linux/amd64 only, never push, skip the shared cache-to. Fast PR feedback. - smoke: - required: false - type: boolean - default: false - # Version from the orchestrator's single NBGV run (threaded). This task does not - # re-run NBGV on the detached commit, which could classify the branch differently. - semver2: - required: true - type: string - assembly_version: - required: true - type: string - assembly_file_version: - required: true - type: string - assembly_informational_version: - required: true - type: string - -jobs: - - build-docker: - name: Build Docker image job - runs-on: ubuntu-latest - env: - # Multi-arch (amd64+arm64) only on a full main publish; smoke and develop build amd64 only. - PLATFORMS: ${{ (!inputs.smoke && inputs.branch == 'main') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - - steps: - - - name: Checkout step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref }} - - # arm64 is non-native on the amd64 runner, so install its QEMU emulator only when the build includes it. - - name: Setup QEMU step - if: ${{ contains(env.PLATFORMS, 'arm64') }} - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - with: - platforms: arm64 - - - name: Setup Buildx step - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - with: - platforms: ${{ env.PLATFORMS }} - - # Always login (even on smoke) for higher pull/cache-read rate limits; the credentials are in both the - # Actions and Dependabot secret stores so a Dependabot push CI run can log in too. Forks cannot push here. - - name: Login to Docker Hub step - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Docker build and push step - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - push: ${{ inputs.push }} - file: ./Docker/Dockerfile - tags: | - docker.io/ptr727/photocleaner:${{ inputs.branch == 'main' && 'latest' || 'develop' }} - docker.io/ptr727/photocleaner:${{ inputs.semver2 }} - platforms: ${{ env.PLATFORMS }} - # Read both branches' caches (near-identical layers), write only this branch's tag and only when pushing. - # mode=max caches the builder publish; ignore-error keeps a cache hiccup from failing a publish. - cache-from: | - type=registry,ref=docker.io/ptr727/photocleaner:buildcache-main - type=registry,ref=docker.io/ptr727/photocleaner:buildcache-develop - cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/photocleaner:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} - build-args: | - LABEL_VERSION=${{ inputs.semver2 }} - BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }} - BUILD_VERSION=${{ inputs.assembly_version }} - BUILD_FILE_VERSION=${{ inputs.assembly_file_version }} - BUILD_ASSEMBLY_VERSION=${{ inputs.assembly_version }} - BUILD_INFORMATION_VERSION=${{ inputs.assembly_informational_version }} - BUILD_PACKAGE_VERSION=${{ inputs.semver2 }} - - # Push the trimmed Docker Hub overview from Docker/README.md, main only (one overview, no per-branch context). - - name: Update Docker Hub description step - if: ${{ inputs.push && inputs.branch == 'main' }} - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - repository: ptr727/photocleaner - readme-filepath: ./Docker/README.md diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml deleted file mode 100644 index 09a0eb6..0000000 --- a/.github/workflows/build-executable-task.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Build executable task - -# Build the executable across the runtime matrix; on a non-smoke run, zip all runtimes into PhotoCleaner.7z (per-RID -# subfolders) as the release-asset--executable artifact. Smoke builds a 2-runtime subset and skips the zip. -on: - workflow_call: - inputs: - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch: main => Release, else Debug. Required (see header). - branch: - required: true - type: string - # Smoke: build linux-x64 + win-x64 only (not the full matrix), skip the zip. Fast PR feedback. - smoke: - required: false - type: boolean - default: false - # Version from the orchestrator's single NBGV run (threaded). This task does not - # re-run NBGV on the detached commit, which could classify the branch differently. - semver2: - required: true - type: string - assembly_version: - required: true - type: string - assembly_file_version: - required: true - type: string - assembly_informational_version: - required: true - type: string - -jobs: - - build-executable-matrix: - name: Build executable project matrix job - runs-on: ubuntu-latest - strategy: - matrix: - runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }} - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref }} - - - name: Build executable project step - run: | - set -Eeuo pipefail - dotnet publish ./PhotoCleaner/PhotoCleaner.csproj \ - --runtime ${{ matrix.runtime }} \ - -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \ - --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ - -property:PublishAot=false \ - -property:Version=${{ inputs.assembly_version }} \ - -property:FileVersion=${{ inputs.assembly_file_version }} \ - -property:AssemblyVersion=${{ inputs.assembly_version }} \ - -property:InformationalVersion=${{ inputs.assembly_informational_version }} \ - -property:PackageVersion=${{ inputs.semver2 }} - - # Smoke just proves each runtime compiles; the per-runtime upload feeds the release zip, so skip it on - # smoke (also keeps the branch out of the artifact name, which a feature branch's `/` would invalidate). - - name: Upload matrix build artifacts step - if: ${{ !inputs.smoke }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: publish-${{ inputs.branch }}-${{ matrix.runtime }} - path: ${{ runner.temp }}/publish - retention-days: 1 - - # Aggregate every runtime into one PhotoCleaner.7z (skipped on smoke; the zip is a release concern). The release - # job collects the release-asset--* artifacts by pattern. - upload-build-artifacts: - name: Upload matrix build artifacts job - if: ${{ !inputs.smoke }} - runs-on: ubuntu-latest - needs: [ build-executable-matrix ] - - steps: - - - name: Download matrix build artifacts step - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: publish-${{ inputs.branch }}-* - merge-multiple: true - path: ${{ runner.temp }}/publish - - - name: Zip build output step - run: 7z a -t7z ${{ runner.temp }}/PhotoCleaner.7z ${{ runner.temp }}/publish/* - - - name: Upload build artifacts step - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-asset-${{ inputs.branch }}-executable - path: ${{ runner.temp }}/PhotoCleaner.7z - retention-days: 1 diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml deleted file mode 100644 index 02114ed..0000000 --- a/.github/workflows/build-release-task.yml +++ /dev/null @@ -1,225 +0,0 @@ -name: Build project release task - -# Orchestrate one branch's release: version once (get-version), build the executable 7z and the Docker image, then -# create the GitHub release. github/dockerhub gate the two publish targets; smoke builds everything, publishes nothing. -on: - workflow_call: - inputs: - github: - required: false - type: boolean - default: false - dockerhub: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch driving config, tags, and prerelease. Required (see header); the publisher passes the run's branch. - branch: - required: true - type: string - # Smoke: reduced, never-published build for fast PR feedback; hard-disables every push below. - smoke: - required: false - type: boolean - default: false - # Set false for a repo that produces no release-asset-* files (e.g. Docker-only): the release is then just the - # tag + source zip + README + LICENSE; the artifact download is skipped and the unmatched-files guard relaxes. - expect_release_assets: - required: false - type: boolean - default: true - -jobs: - - # Validate the branch being published (unit tests + lint), gating the build/publish below. Skipped on - # smoke: the PR runs its own validate job, so a smoke build does not double-validate. - validate: - name: Validate job - if: ${{ !inputs.smoke }} - uses: ./.github/workflows/validate-task.yml - # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. validate-task takes no ref input; - # a publish run's github.ref is already the trigger branch, so the default checkout validates the right branch. - secrets: inherit - - get-version: - name: Get version information job - uses: ./.github/workflows/get-version-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - - # Entry gate: validate branch<->version consistency once, before the build jobs, so an NBGV mis-classification fails - # fast instead of after building and publishing. main must be a public release (no prerelease '-'); every other branch - # must carry a prerelease '-' (guards a develop leg being classified public and published as stable). Strip - # '+buildmetadata' first; a '-' there is legitimate, only a '-' in the core/prerelease segment marks a prerelease. - validate-release: - name: Validate release version job - needs: [get-version] - runs-on: ubuntu-latest - steps: - - name: Validate branch and version consistency step - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - BRANCH: ${{ inputs.branch }} - SMOKE: ${{ inputs.smoke }} - run: | - set -Eeuo pipefail - # Smoke builds never publish and always version as prerelease (detached PR HEAD), which would trip the main arm. - if [[ "$SMOKE" == "true" ]]; then - echo "Smoke build; skipping release version validation." - exit 0 - fi - CORE_AND_PRE="${SEMVER2%%+*}" - if [[ "$BRANCH" == "main" ]]; then - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - elif [[ "$CORE_AND_PRE" != *-* ]]; then - echo "::error::Prerelease ($BRANCH) version '$SEMVER2' has no prerelease suffix (NBGV classified it public); refusing to publish." - exit 1 - fi - - # Build only when validation passed (success) or was skipped (smoke); never when it failed. validate-release is the - # entry gate above, so it must be green before anything is built. - build-executable: - name: Build executable job - needs: [get-version, validate, validate-release] - if: ${{ !cancelled() && needs.get-version.result == 'success' && needs.validate-release.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} - uses: ./.github/workflows/build-executable-task.yml - secrets: inherit - with: - # Pin to the resolved commit so the artifacts match the release tag, and thread the single NBGV version. - ref: ${{ needs.get-version.outputs.GitCommitId }} - branch: ${{ inputs.branch }} - smoke: ${{ inputs.smoke }} - semver2: ${{ needs.get-version.outputs.SemVer2 }} - assembly_version: ${{ needs.get-version.outputs.AssemblyVersion }} - assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} - assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} - - # The terminal publish target, so it needs every other build (D4.5). - # The image builds from source and consumes no executable artifact, so nothing else forces the ordering. - # Without it a failed build-executable skips github-release while the image still pushes and `latest` moves. - # That is a partial publish: an image with no release. - # `!failure()` is what blocks it, since a failed upstream build must stop the push where a skipped one must not. - # `validate` is skipped on smoke, which is the skip that must still build. - build-docker: - name: Build Docker job - needs: [get-version, validate, validate-release, build-executable] - if: ${{ !failure() && !cancelled() && needs.get-version.result == 'success' && needs.validate-release.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} - uses: ./.github/workflows/build-docker-task.yml - secrets: inherit - with: - # Pin to the resolved commit so the artifacts match the release tag, and thread the single NBGV version. - ref: ${{ needs.get-version.outputs.GitCommitId }} - branch: ${{ inputs.branch }} - smoke: ${{ inputs.smoke }} - semver2: ${{ needs.get-version.outputs.SemVer2 }} - assembly_version: ${{ needs.get-version.outputs.AssemblyVersion }} - assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} - assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} - # Push to Docker Hub, never on a smoke build. - push: ${{ inputs.dockerhub && !inputs.smoke }} - - github-release: - name: Publish GitHub release job - # `!inputs.smoke` enforces "smoke never publishes" at the job level, so a smoke caller that also set - # `github: true` still can't create a release. - if: ${{ inputs.github && !inputs.smoke }} - runs-on: ubuntu-latest - needs: [get-version, validate-release, build-executable, build-docker] - - steps: - - # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run. - - name: Checkout code step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.get-version.outputs.GitCommitId }} - - # Collect assets by the `release-asset--*` pattern so this step is target-agnostic. - # Subset releases by deleting the target rather than by `enable_*: false`, since a skipped `needs` job would skip this release job too. - # The release step guards `fail_on_unmatched_files: true`, so at least one `release-asset-*` must match. - # A repo that drops every file-producing target relaxes that guard. - # A Docker-only repo is the case, its release carrying only the source zip + README + LICENSE. - - name: Download release asset artifacts step - if: ${{ inputs.expect_release_assets }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: release-asset-${{ inputs.branch }}-* - merge-multiple: true - path: ./Publish - - # The weekly publisher re-runs even with no new commits, so the version may already be released. - # Skip the release step when a release for this tag already exists, to avoid a no-op republish. - - name: Check for existing release step - id: release-exists - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -Eeuo pipefail - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "Release $TAG already exists; workflow_dispatch will refresh it." - else - echo "Release $TAG already exists; skipping release creation (no-op republish)." - fi - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - # `target_commitish` must be set explicitly: otherwise GitHub's REST API tags the release on the default branch. - # Pin it to `GitCommitId` so the tag is on the exact built commit, consistent with the SemVer2 tag and artifacts. - # Skip when the release already exists, but always let a manual `workflow_dispatch` through to refresh it. - # Every release, on any branch and for any target, is a tag on the built commit plus the auto-attached source zip, README, and LICENSE. - # Targets amend it by uploading `release-asset-*` files (binaries/packages) or by pushing elsewhere (image/registry). - # `fail_on_unmatched_files: true` fails loudly if a promised `release-asset-*` is missing or misnamed. - # A no-file-target repo relaxes it (see download step). - - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - generate_release_notes: true - tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ needs.get-version.outputs.GitCommitId }} - prerelease: ${{ inputs.branch != 'main' }} - fail_on_unmatched_files: ${{ inputs.expect_release_assets }} - files: | - LICENSE - README.md - ./Publish/* - - # Surgical cleanup at the point of consumption, since the release-asset--* transfer artifacts now have durable copies on the release. - # Delete them by exact pattern to free the storage quota. - # The pattern is scoped to this branch's assets, leaving diagnostics and any other artifacts. - # It is gated to the same condition as the create step, so it only deletes when a release was actually created or refreshed this run. - # On a skipped create (an existing tag, no new commits) the fresh artifacts stay for the run, reaped by the retention-days: 1 backstop. - # It needs the caller to grant `actions: write`, which publish-release's publish job does. - - name: Delete consumed release asset artifacts step - if: ${{ inputs.expect_release_assets && (steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch') }} - # Best-effort, since the release is already published, so a listing or delete hiccup must never red the job. - # The retention-days: 1 backstop reaps anything missed. - # It deletes every matching id, because a rerun can upload duplicates. - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -Eeuo pipefail - if ! ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.run_id }}/artifacts" --paginate \ - --jq ".artifacts[] | select(.name | startswith(\"release-asset-${{ inputs.branch }}-\")) | .id"); then - echo "::warning::Could not list run artifacts; retention-days backstop will reap them." - ids="" - fi - for id in $ids; do - if ! gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/artifacts/$id"; then - echo "::warning::Failed to delete artifact $id; retention-days backstop will reap it." - fi - done diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml deleted file mode 100644 index 8dfb67f..0000000 --- a/.github/workflows/get-version-task.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Get version information task - -# Run NBGV once and expose the version outputs. The publisher passes its trigger branch as ref so the run -# versions the branch it publishes; github.ref matches that branch (one branch per run), so NBGV classifies it -# correctly without overriding github.ref. -on: - workflow_call: - inputs: - # Git ref to check out / version (empty = caller's default checkout ref). - ref: - required: false - type: string - default: '' - outputs: - SemVer2: - value: ${{ jobs.get-version.outputs.SemVer2 }} - AssemblyVersion: - value: ${{ jobs.get-version.outputs.AssemblyVersion }} - AssemblyFileVersion: - value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: - value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} - # Full SHA NBGV versioned; pins the release tag to the exact built commit, not a moving ref. - GitCommitId: - value: ${{ jobs.get-version.outputs.GitCommitId }} - -jobs: - - get-version: - name: Get version information job - runs-on: ubuntu-latest - outputs: - SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} - AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} - AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} - GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - - # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot - # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1). - - name: Run Nerdbank.GitVersioning tool step - id: nbgv - uses: dotnet/nbgv@master diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 931ed93..a9234c6 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -6,9 +6,13 @@ name: Publish project release action # stable/`latest`, develop => prerelease/`develop`). Building only the trigger branch keeps github.ref aligned # with the branch being versioned, so NBGV classifies it correctly with no matrix and no cross-branch ref leak. # -# Merges do NOT publish: accumulated changes (mostly Dependabot bumps) ship in the next scheduled run. To -# release develop, dispatch this workflow from the develop branch. CI/validation runs separately on push -# (test-pull-request); this never runs on push. +# Merges do NOT publish: accumulated changes (mostly Dependabot bumps) ship in the next scheduled run. +# To release develop, dispatch this workflow from the develop branch. +# CI/validation runs separately in test-pull-request.yml on pull request. +# This workflow never runs on push or pull request. +# +# Thin caller onto the hub's reusable plan, validate, and build-release tasks, per ptr727/ProjectTemplate docs/reusable-workflows.md "Adopting the Release Chain". +# The plan job centralizes the release-gate decision this workflow's own trigger already narrows to main/develop, so publish keeps the same behavior as before adoption. on: workflow_dispatch: schedule: @@ -23,20 +27,43 @@ concurrency: jobs: + # Single source of the release-gate decision (whether to publish, and whether the channel is stable), reused by every job below. + # A dispatch publishes only main or develop. + # The schedule always publishes, matching this workflow's own trigger. + plan: + name: Plan release job + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + with: + event_name: ${{ github.event_name }} + actor: ${{ github.actor }} + ref_name: ${{ github.ref_name }} + + # The same reusable gate the pull request runs, on the branch tip. + # It runs only when a publish will happen. + validate: + name: Validate job + needs: [plan] + if: ${{ needs.plan.outputs.publish == 'true' }} + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # Publish the trigger branch in full (native binaries + Docker + GitHub release). The schedule always runs on # the default branch (main); a dispatch runs on whatever branch it is started from. build-release-task # validates, versions, and tags that one branch; the no-op guard skips release creation when the version is # unchanged while the Docker push still refreshes the base image. publish: name: Publish project release job - # Only the long-lived branches publish; a stray dispatch from a feature branch is a no-op. - if: ${{ github.ref_name == 'main' || github.ref_name == 'develop' }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit + needs: [plan, validate] + if: ${{ needs.plan.outputs.publish == 'true' }} + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 permissions: contents: write # actions: write lets the reusable github-release job delete its release-asset-* transfer artifacts (D5.1). actions: write + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} with: # Pin to the exact triggering commit (not the moving branch tip) so get-version, the builds, and the release # tag all use one commit even if the branch advances mid-run. branch stays the logical name for config/tags. @@ -45,3 +72,9 @@ jobs: smoke: false github: true dockerhub: true + enable_docker: true + enable_executable: true + enable_nuget: false + enable_pypi: false + docker_image: ptr727/photocleaner + executable_project: ./PhotoCleaner/PhotoCleaner.csproj diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index f89365b..02324a8 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,62 +1,90 @@ name: Test pull request action -# CI for every branch. Runs on push so the reusable validate-task resolves from the pushed head: a PR that -# edits a workflow tests its own copy. The aggregator below is the ruleset required-status-check, produced -# here on the head SHA. There is no build/publish job in this gate - unit-test already builds the projects. -# -# No pull_request trigger: a fork PR cannot push to this repo, so it produces no run and cannot satisfy the -# required check. A maintainer lands such a contribution on an in-repo branch (which pushes, and so validates). +# Thin caller onto the hub's reusable validate-task.yml and build-release-task.yml. +# This is the release-with-smoke shape from ptr727/ProjectTemplate docs/reusable-workflows.md "Adopting the Gates". on: - # All branches, but not tags. - push: - branches: ['**'] + pull_request: + branches: [main, develop] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -# Least-privilege token: checkout only. The Codecov upload uses CODECOV_TOKEN (not GITHUB_TOKEN). -permissions: - contents: read +permissions: {} jobs: - # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fail). + # The repo builds one combined release target, executable plus Docker image. + # One filter entry excludes a workflow-only change from the smoke build rather than separating targets the repo does not have. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + release: ${{ steps.filter.outputs.release }} + steps: + - name: Checkout code step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Filter changed paths step + id: filter + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + release: + - '!.github/workflows/**' + validate: - name: Validate job - if: ${{ !github.event.deleted }} - uses: ./.github/workflows/validate-task.yml - secrets: inherit + name: Validate sources job + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + permissions: + contents: read + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # Reduced, never-published build of the release targets (executable subset + amd64 Docker image, no push) so a - # PR exercises the reusable build pipeline. smoke: true hard-disables every publish path in build-release-task. + # Never publishes and never uploads (D1.3): smoke: true disables every publish path in build-release-task. + # The enable_nuget and enable_pypi inputs are false, since this repo carries neither leaf. + # The Docker Hub secrets are mapped even on smoke, since the hub task logs in to Docker Hub on every build for the higher rate limit. smoke-build: name: Smoke build job - if: ${{ !github.event.deleted }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit + needs: [changes] + if: needs.changes.outputs.release == 'true' + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@0b07a59d7c65d07d8df275a96deaf2e06cbefd51 # 2.0.352 + permissions: + contents: read + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} with: smoke: true github: false dockerhub: false - branch: ${{ github.ref_name }} + enable_docker: true + enable_executable: true + enable_nuget: false + enable_pypi: false + docker_image: ptr727/photocleaner + executable_project: ./PhotoCleaner/PhotoCleaner.csproj + # On a pull_request event github.ref_name is the PR ref (for example 123/merge), never the target branch. + # The logical branch reads base_ref first and only falls back to ref_name on a non-PR trigger. + branch: ${{ github.base_ref || github.ref_name }} - # Single required status check. Its name is the ruleset-bound context - do not rename without updating the - # ruleset in lockstep. Must succeed, not merely not-fail. + # Treats a skipped smoke-build (an unchanged target) as pass, and blocks on failure or cancelled (D1.5, D7.4). check-workflow-status: name: Check pull request workflow status job runs-on: ubuntu-latest - needs: [validate, smoke-build] - if: ${{ always() && !github.event.deleted }} + needs: [changes, validate, smoke-build] + if: always() steps: - name: Check workflow results step run: | set -Eeuo pipefail - for result in "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do + for result in "changes:${{ needs.changes.result }}" "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do name="${result%%:*}" value="${result#*:}" - if [[ "$value" != "success" ]]; then + if [[ "$value" != "success" && "$value" != "skipped" ]]; then echo "::error::Job '$name' did not succeed ($value)." exit 1 fi diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml deleted file mode 100644 index 2c90e16..0000000 --- a/.github/workflows/validate-task.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Validate task - -# The single validation gate: unit tests (with coverage) plus the lint set (the editor's checks enforced in -# CI from the same config files). Reused by test-pull-request, which produces the required status check. -on: - workflow_call: - secrets: - # Codecov upload token. Optional: the upload is report-only (fail_ci_if_error: false) so a repo without - # the secret still passes validation. - CODECOV_TOKEN: - required: false - -# Least-privilege token: checkout only. The Codecov upload uses CODECOV_TOKEN (not GITHUB_TOKEN). -permissions: - contents: read - -jobs: - - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here. - # --collect drives coverlet.collector to emit Cobertura XML into ./coverage//. - - name: Run unit tests step - run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage - - # Report-only: fail_ci_if_error is false so a Codecov hiccup or an absent token never fails the gate. - - name: Upload coverage to Codecov step - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - directory: ./coverage - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - # The editor's checks, enforced in CI from the same config files. cspell is a deliberate follow-up. - lint: - name: Lint job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - # The prose gate diffs against the base branch, so it needs that branch's history. - # Only the runs that reach the gate pay for it, since a publish run skips the gate. - # A full clone there is time and network a release does not need. - # The condition matches the gate's own below, so the two cannot drift apart silently. - # - # The depths are quoted, and must stay quoted. - # An unquoted 0 is falsy, so `cond && 0 || 1` collapses to 1 whenever cond is true. - # Every run would then shallow-clone, leaving the gate no base to diff against. - fetch-depth: ${{ (github.event_name == 'push' && github.ref_name != 'main') && '0' || '1' }} - - - name: Check C# formatting step - run: | - set -Eeuo pipefail - dotnet tool restore - dotnet csharpier check . - - - name: Check C# style step - run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - - - name: Lint markdown step - uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0 - with: - globs: '**/*.md' - - - name: Spell check step - uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 - with: - files: | - README.md - HISTORY.md - incremental_files_only: false - - - name: Lint workflows step - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 - - - name: Check EditorConfig step - run: docker run --rm -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest - - # The fleet prose rules live in the hub, so this repo consumes them rather than vendoring them. - # The rules are read from hub develop, so a rule change is exercised here before promotion. - # Only lines a change touches are reported, so the existing backlog blocks nothing. - # - # The base is the branch this one merges into. - # The gate runs on push, so there is no pull_request event to read a base from. - # Runs on main are skipped, since it only receives promotion merges already gated on develop. - # - # A publish run reaches this workflow too, through build-release-task, and is skipped. - # Its content was already gated when it was pushed. - # Re-reading it would diff the whole unpromoted delta and could fail a release on prose. - - name: Check prose step - if: ${{ github.event_name == 'push' && github.ref_name != 'main' }} - # A SHA pin, containing the prose gate from ptr727/ProjectTemplate#520. - # It was hub develop when pinned, and says nothing about where develop is now. - # A pin that claims to be a branch goes stale the moment that branch moves. - # It carries no release tag yet, so Dependabot cannot compare it and will not bump it. - # Repoint it by hand at the next hub release. - # Dependabot tracks it like any other action pin from then on. - uses: ptr727/ProjectTemplate/.github/actions/prose-gate@cbd5eb3c9e079b640d5461400d89b390de2f0780 # hub develop - with: - base: origin/${{ github.ref_name == 'develop' && 'main' || 'develop' }} diff --git a/OPERATIONS.md b/OPERATIONS.md index 7cfad83..f0bc01b 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -82,5 +82,5 @@ The Immich API key can be given inline with `--apikey` or read from a file with - [PhotoCleanerTests/](./PhotoCleanerTests/) is the xUnit suite, and [PhotoCleanerBenchmarks/](./PhotoCleanerBenchmarks/) is the benchmark project. - [Docker/](./Docker/) holds the multi-architecture `Dockerfile` and the Docker Hub README. - [repo-config/](./repo-config/) holds the branch rulesets and the apply script. It sits outside `.github/`, which is Actions-owned. -- [.github/workflows/](./.github/workflows/) holds the CI and release pipelines, with `validate-task.yml` as the single validation gate that the pull request check and the release both call. +- [.github/workflows/](./.github/workflows/) holds the CI and release pipelines. The pull request check and the release chain both reach the hub-hosted `validate-task.yml` and `build-release-task.yml` in ptr727/ProjectTemplate rather than carrying their own copies. - Analyzer and package configuration is central: `Directory.Build.props` carries the analyzer set, and `Directory.Packages.props` pins every package version. From 75b8ce4724d820ea20bbe0f676877becc691b423 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 15:03:20 -0700 Subject: [PATCH 2/2] Fetch Full History in the Changed-Targets Filter Job A workflow_dispatch run of test-pull-request.yml has no pull request to read the changed-files list from, so dorny/paths-filter falls back to a local diff against the default branch, which a shallow checkout could starve of a common ancestor. Raised by the Copilot review on PR #55. --- .github/workflows/test-pull-request.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 02324a8..0f69f44 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -26,8 +26,13 @@ jobs: outputs: release: ${{ steps.filter.outputs.release }} steps: + # Full history: a pull_request run reads the changed-files list from the GitHub API and needs no history. + # A workflow_dispatch run has no PR to query, so dorny/paths-filter diffs locally against the default branch instead. + # A shallow checkout could leave it with no common ancestor to diff from. - name: Checkout code step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Filter changed paths step id: filter uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1