diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 2b10359b..c6574309 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "dotnet-outdated-tool": { - "version": "4.7.1", + "version": "4.8.0", "commands": [ "dotnet-outdated" ], diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b6863ac2..df3a6198 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,9 +19,11 @@ For full rationale see [`AGENTS.md`](../AGENTS.md). Quick rules: - `feature → develop → main`. PRs only. - Develop accepts **squash merges only**; main accepts **merge commits only**. Don't suggest rebase-merge — it's disabled at the repo level. -- Both branches **auto-publish on push**: develop produces NBGV prereleases (`X.Y.Z-g{sha}`) tagged `develop` on Docker Hub; main produces stable releases (`X.Y.Z`) tagged `latest`. +- **Two-phase publishing.** PRs only **smoke-build** changed targets (Docker `linux/amd64`, a 2-runtime executable subset, no push). `publish-release.yml` is the sole publisher: its **weekly schedule + manual dispatch** build/publish **both** branches (develop ⇒ NBGV prereleases `X.Y.Z-g{sha}` tagged `develop`; main ⇒ stable `X.Y.Z` tagged `latest`). Routine merges do **not** publish unless the `PUBLISH_ON_MERGE` repo variable is `true`. - Dependabot targets **both** `main` and `develop` with the same ecosystems; major NuGet bumps gate on human review, everything else auto-merges via App-token-driven merge-bot. - Every third-party GitHub Action is pinned to a full commit SHA with a `# vX.Y.Z` comment. Don't introduce `@v6` / `@main` / `@master` floating refs. +- Never merge a PR without a fresh "no issues found" review from `copilot-pull-request-reviewer[bot]` (shown as "Copilot" in the UI) on the latest commit. `mergeStateStatus: CLEAN` is necessary but not sufficient — Copilot's re-review of the latest push is required. Re-request the review **programmatically** after every push via the `requestReviews` GraphQL mutation (don't wait on flaky auto-review-on-push) — see the [GitHub Copilot Review Runbook](#github-copilot-review-runbook) below and [`AGENTS.md`](../AGENTS.md#merging-a-pr). +- After a develop → main merge lands and main's publish workflows complete, bump the minor in `version.json` on develop (e.g. `3.16` → `3.17`) via an isolated `bump-version-X.Y` PR. Without it, develop's next prerelease version numbers fall below main's just-shipped stable. - Don't recommend `git push --force` or `--force-with-lease`; both rulesets enforce `non_fast_forward`. - `version.json`'s `publicReleaseRefSpec` is `^refs/heads/main$` — bumping the base `version` field is the only manual versioning action. @@ -300,11 +302,13 @@ dotnet husky run ### GitHub Actions -- **publish-release.yml**: Multi-runtime matrix build (win, linux, osx x x64/arm/arm64) -- **publish-periodic-docker-release.yml**: Multi-arch Docker builds (linux/amd64, linux/arm64) -- **test-pull-request.yml**: PR validation -- Version info: `version.json` with Nerdbank.GitVersioning format -- Branches: `main` (stable releases), `develop` (pre-releases) +Two-phase model — reusable `*-task.yml` workflows orchestrated by two entry points: + +- **test-pull-request.yml**: PR validation. `changes` (dorny/paths-filter) → always-on `unit-test` (Husky) + path-gated `smoke-build` (reduced, no-push) → `Check pull request workflow status` aggregator (ruleset-bound name; requires `changes` succeeded). +- **publish-release.yml**: the **sole publisher** (`push` + weekly `schedule` + `workflow_dispatch`). A `setup` job computes the branch list + publish gate; the `publish` matrix builds both branches via `build-release-task.yml` (executable 7-RID matrix + multi-arch Docker `linux/amd64,linux/arm64` + GitHub release), then `tool-versions`, `docker-readme` (main only), `date-badge` (main only). +- Reusable tasks: `build-release-task.yml`, `build-executable-task.yml`, `build-docker-task.yml`, `build-toolversions-task.yml`, `build-dockerreadme-task.yml`, `build-datebadge-task.yml`, `get-version-task.yml`. All thread a required `branch` input (config keys off it, never `github.ref_name`) plus `ref`/`smoke`. +- Version info: `version.json` with Nerdbank.GitVersioning format. `get-version-task.yml` surfaces `SemVer2`, the assembly versions, and `GitCommitId` (used to pin the release `target_commitish`). +- Branches: `main` (stable releases, `latest`), `develop` (pre-releases, `develop`). ### Docker @@ -462,9 +466,131 @@ Check states with `HasFlag()`, combine with `|=` ## Git and Commit Rules -**These rules are absolute — no exceptions:** - -- **Never make git commits.** All commits must be cryptographically signed (SSH/GPG). AI coding agents cannot produce signed commits. Stage changes with `git add` and leave `git commit` to the developer, who must run it in their own environment where signing keys are available. +- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless explicitly authorized to commit for the current ask ("commit this", "open a PR"). Authorization is scope-bound to that task. +- **All commits must be cryptographically signed (SSH/GPG)** — branch protection rejects unsigned commits. Signing depends on environment config (`commit.gpgsign`, a `user.signingkey`, a loaded agent). If signing isn't configured, **do not commit** — stop at `git add` and surface it. Verify first: `git config --get commit.gpgsign && ssh-add -L`. - **Never force push.** Do not run `git push --force` or `git push --force-with-lease`. Force pushing rewrites shared branch history and is blocked by branch protection rules. - **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. -- **Staging is the limit.** Prepare changes and stage files; the developer handles all commits and pushes. +- **The `develop → main` release merge is maintainer-only.** Drive `feature → develop` PRs end-to-end when authorized (commit, push, Copilot review loop, squash-merge), but never self-merge a release to `main`. + +## GitHub Copilot Review Runbook + +Provider-specific mechanics for driving GitHub Copilot reviews entirely via `gh`/GraphQL — no manual UI clicks. The review-loop *contract* (re-request on every push, verify head-SHA coverage, triage, reply + resolve, escalate when stuck) is in [AGENTS.md → Merging a PR](../AGENTS.md#merging-a-pr); this section is how to make Copilot reliably execute it. + +### Triggering and Polling + +Auto-review on push is configured (the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently — treat it as best-effort. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This drives the loop end-to-end without a maintainer clicking "re-request review" in the UI. + +> **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. (The prose elsewhere referring to `copilot-pull-request-reviewer[bot]` is describing the REST/display login.) + +```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: "ptr727", name: "PlexCleaner") { + 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 — auto-review-on-open normally supplies the first. If none 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. The Copilot reviewer bot's global node id is `BOT_kgDOCnlnWA` (login `copilot-pull-request-reviewer`) if you need to skip discovery. + +**Do NOT post `@Copilot review` as a PR comment.** That triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. + +Known non-working request paths (use the `requestReviews` mutation instead): + +- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. +- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. + +### Verify Review Covered Current Head + +Before merging, confirm Copilot reviewed the current PR head SHA. Copilot may respond as a formal review (carries an exact commit SHA) or an issue comment (no SHA). Check both. + +```sh +PR_HEAD=$(gh pr view --json headRefOid --jq '.headRefOid') + +# 1. Formal review — exact SHA match. +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. This is the REST API, so the login carries the `[bot]` suffix. +gh api repos/ptr727/PlexCleaner/issues//comments --jq \ + '[.[] | 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 (commit timestamps can predate the push). Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. + +### Bounded Retry Workflow + +If a review did not run on the current head: + +1. Wait briefly and check head-SHA coverage (above). +1. Re-request via the `requestReviews` mutation; 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 the review blocked and escalate to the maintainer with what was attempted. + +### Reply and Thread Resolution Workflow + +List unresolved threads (`first: 100` + cursor pagination; if `hasNextPage`, re-run with `after: ""`): + +```sh +gh api graphql -f query=' +{ + repository(owner: "ptr727", name: "PlexCleaner") { + pullRequest(number: ) { + reviewThreads(first: 100) { + nodes { + id isResolved path + comments(first: 1) { nodes { author { login } body } } + } + pageInfo { hasNextPage endCursor } + } + } + } +}' | jq ' + .data.repository.pullRequest.reviewThreads | + (.pageInfo | "hasNextPage=\(.hasNextPage) endCursor=\(.endCursor)"), + (.nodes[] | select(.isResolved == false)) +' +``` + +Reply on a thread, then resolve it: + +```sh +gh api graphql -f query=' +mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}' -F threadId="PRRT_..." -F body="Fixed in : ." + +gh api graphql -f query=' +mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } +}' -F threadId="PRRT_..." +``` + +Issue-level Copilot comments (those in `issues//comments`) have no resolution action — reply if the finding warrants it; no resolution step is possible. + +Reply-body conventions: + +- Accepted bug/style fix: include fixing commit SHA and a one-line summary. +- Declined style comment: cite the rule (AGENTS.md or CODESTYLE) and the existing-tree precedent. +- Declined architecture proposal: one-sentence rationale. + +A PR is mergeable when `mergeStateStatus == CLEAN` and there are 0 unresolved threads on the current head. After the final push, sweep-resolve stale older threads for removed code paths. diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index e6cb9dfc..437879a6 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@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 with: name: lastbuild diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 922af0fe..64640089 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,56 +35,80 @@ 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 runs-on: ubuntu-latest needs: [get-version] - strategy: - matrix: - include: - - file: ./Docker/Dockerfile - platforms: linux/amd64,linux/arm64 - tags: | - docker.io/ptr727/plexcleaner:${{ github.ref_name == 'main' && 'latest' || 'develop' }} - docker.io/ptr727/plexcleaner:${{ needs.get-version.outputs.SemVer2 }} steps: - name: Checkout step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + 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 - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + if: ${{ !inputs.smoke }} + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 with: platforms: linux/amd64,linux/arm64 - name: Setup Buildx step - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.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 + # because PlexCleaner's contribution flow is same-repo branch PRs + + # Dependabot (App token); a fork-PR Docker smoke path would alternatively + # gate this step on `inputs.push` and accept anonymous-rate-limited reads. - name: Login to Docker Hub step - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Docker build and push step - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: ${{ inputs.push }} - file: ${{ matrix.file }} - tags: ${{ matrix.tags }} - platforms: ${{ matrix.platforms }} - cache-from: type=registry,ref=docker.io/ptr727/plexcleaner:buildcache - cache-to: type=registry,ref=docker.io/ptr727/plexcleaner:buildcache,mode=max + file: ./Docker/Dockerfile + tags: | + docker.io/ptr727/plexcleaner:${{ inputs.branch == 'main' && 'latest' || 'develop' }} + docker.io/ptr727/plexcleaner:${{ needs.get-version.outputs.SemVer2 }} + 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). + # `mode=max` caches the multi-stage builder layers (the expensive + # `dotnet publish`); `ignore-error=true` keeps a transient cache + # export failure from failing an otherwise-good publish. Registry + # (not gha) cache because the weekly publish cadence would let gha's + # 7-day unused-entry eviction drop branch caches between publishes. + cache-from: | + type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-main + type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-develop + cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-{0},mode=max,ignore-error=true', 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-dockerreadme-task.yml b/.github/workflows/build-dockerreadme-task.yml new file mode 100644 index 00000000..a0adf227 --- /dev/null +++ b/.github/workflows/build-dockerreadme-task.yml @@ -0,0 +1,54 @@ +name: Create Docker README.md task + +on: + workflow_call: + inputs: + # Logical branch this run is for. The Docker Hub README is rendered from + # `main` only (it embeds the `latest` image's tool versions); the job + # self-gates so the develop leg of the publisher's branch matrix is a + # no-op. Required (no `github.ref_name` fallback) so the gate can't + # silently misfire. + branch: + required: true + type: string + +jobs: + + docker-readme: + name: Create Docker README.md job + # Render only for main: there is a single Docker Hub README, and the m4 + # template includes the `latest` image's tool versions. + if: ${{ inputs.branch == 'main' }} + runs-on: ubuntu-latest + + steps: + + # Pin to the branch this run is for (always `main` here, via the job + # gate). Without it, checkout uses the triggering ref — a + # `workflow_dispatch` from `develop` would otherwise render the Hub + # README from develop's `Docker/README.m4` instead of main's. + - name: Checkout step + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.branch }} + + # Download by exact name (not a `versions-*` pattern): the main leg's + # artifact contains a file literally named `latest.ver`, which the m4 + # template references via `include({{latest.ver}})`. + - name: Download version artifacts step + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: versions-main + path: ${{ runner.temp }}/versions + + - name: Create README.md from README.m4 step + run: m4 --include=${{ runner.temp }}/versions ./Docker/README.m4 > ${{ runner.temp }}/README.md + + - name: Update Docker Hub README.md step + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + repository: ptr727/plexcleaner + short-description: ${{ github.event.repository.description }} + readme-filepath: ${{ runner.temp }}/README.md diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index d85fd93d..50f5d316 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,24 +41,26 @@ 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: - name: Setup .NET SDK step - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.ref }} - name: Build executable project step run: | dotnet publish ./PlexCleaner/PlexCleaner.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 }} \ @@ -48,11 +71,16 @@ jobs: - name: Upload matrix build artifacts step uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 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 +91,7 @@ jobs: - name: Download matrix build artifacts step uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: publish-* + pattern: publish-${{ inputs.branch }}-* merge-multiple: true path: ${{ runner.temp }}/publish @@ -74,5 +102,5 @@ jobs: id: artifact-upload-step uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: executable-build + name: executable-build-${{ inputs.branch }} path: ${{ runner.temp }}/PlexCleaner.7z diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index e49a381d..e79ed82a 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -13,6 +13,38 @@ 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 PR smoke + # run sets these from the paths-filter so only changed targets build; a + # derived project that drops a target deletes its job below and removes + # it from `github-release`'s `needs`. + enable_docker: + required: false + type: boolean + default: true + enable_executable: + required: false + type: boolean + default: true jobs: @@ -20,30 +52,56 @@ jobs: name: Get version information job uses: ./.github/workflows/get-version-task.yml secrets: inherit + with: + ref: ${{ inputs.ref }} build-executable: name: Build executable job + if: ${{ inputs.enable_executable }} + needs: [get-version] uses: ./.github/workflows/build-executable-task.yml secrets: inherit + with: + # 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 }} + 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: - # Conditional push to Docker Hub - push: ${{ inputs.dockerhub }} + # Pin to the resolved commit (see build-executable) so the pushed image + # contents match the SemVer2 tag computed from the same commit. + ref: ${{ needs.get-version.outputs.GitCommitId }} + 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 push gate 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-executable, build-docker] steps: + # Check out the exact built commit (NBGV `GitCommitId`), 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 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} - name: Download executable build artifacts step uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -51,12 +109,45 @@ 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, 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. A manual + # `workflow_dispatch` is allowed through so it can repair/refresh a + # partially-created release for the same tag. The Docker mutable tags + # (`latest`/`develop`) and base-image refresh still happen regardless. + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "Release $TAG already exists; workflow_dispatch will refresh it." + else + echo "Release $TAG already exists; skipping release creation (no-op republish)." + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # `target_commitish` MUST be set explicitly: softprops doesn't pass a + # default through, and GitHub's REST API then defaults the new tag to the + # repository's default branch (main). Pin it to NBGV's `GitCommitId` — + # the exact commit the version was computed from — not `github.sha` + # (wrong on the develop leg of the publisher's branch matrix) and not a + # branch name (a moving ref a mid-run commit could advance past). - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: 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' }} generate_release_notes: true fail_on_unmatched_files: true files: | diff --git a/.github/workflows/build-toolversions-task.yml b/.github/workflows/build-toolversions-task.yml new file mode 100644 index 00000000..091039c7 --- /dev/null +++ b/.github/workflows/build-toolversions-task.yml @@ -0,0 +1,74 @@ +name: Get tool versions task + +on: + workflow_call: + inputs: + # Logical branch this run is for. Selects the pushed image tag + # (`main` => `latest`, else `develop`) and the per-branch versions + # filename so both legs of the publisher's branch matrix can run in one + # run without artifact collisions. Required (no `github.ref_name` + # fallback) so the develop leg of the matrix isn't mistagged. + branch: + required: true + type: string + +jobs: + + tool-versions: + name: Get tool versions job + runs-on: ubuntu-latest + + steps: + + # Authenticate the implicit `docker pull` below (manifest inspect + + # docker-run-action) so reading the just-pushed image isn't subject to + # anonymous Docker Hub rate limits. + - name: Login to Docker Hub step + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Get image size step + env: + # `main` => the `latest` tag rendered into the Docker Hub README via + # the `latest.ver` m4 include; `develop` => the `develop` tag and a + # `develop.ver` file kept distinct for the matrix (informational — + # the Docker Hub README is rendered from main only). + TAG: ${{ inputs.branch == 'main' && 'latest' || 'develop' }} + FILE: ${{ inputs.branch == 'main' && 'latest.ver' || 'develop.ver' }} + run: | + set -euo pipefail + mkdir -p "${{ runner.temp }}/versions" + touch "${{ runner.temp }}/versions/$FILE" + echo "Image: docker.io/ptr727/plexcleaner:$TAG" >> "${{ runner.temp }}/versions/$FILE" + echo "Size: $(docker manifest inspect -v "docker.io/ptr727/plexcleaner:$TAG" | jq '.[] | select(.Descriptor.platform.architecture=="amd64") | [.OCIManifest.layers[].size] | add' | numfmt --to=iec)" >> "${{ runner.temp }}/versions/$FILE" + + - name: Write tool versions to file step + uses: addnab/docker-run-action@4f65fabd2431ebc8d299f8e5a018d79a769ae185 # v3 + with: + image: docker.io/ptr727/plexcleaner:${{ inputs.branch == 'main' && 'latest' || 'develop' }} + options: --volume ${{ runner.temp }}/versions:/versions + run: | + FILE="${{ inputs.branch == 'main' && 'latest.ver' || 'develop.ver' }}" + echo OS: $(. /etc/os-release; echo $PRETTY_NAME) >> /versions/$FILE + echo dotNET: $(dotnet --info) >> /versions/$FILE + echo PlexCleaner: $(/PlexCleaner/PlexCleaner --version) >> /versions/$FILE + echo HandBrakeCLI: $(HandBrakeCLI --version) >> /versions/$FILE + echo MediaInfo: $(mediainfo --version) >> /versions/$FILE + echo MkvMerge: $(mkvmerge --version) >> /versions/$FILE + echo FfMpeg: $(ffmpeg -version) >> /versions/$FILE + + - name: Print versions step + env: + FILE: ${{ inputs.branch == 'main' && 'latest.ver' || 'develop.ver' }} + run: cat "${{ runner.temp }}/versions/$FILE" + + - name: Upload version artifacts step + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # Branch-suffixed so both matrix legs coexist; the inner filename + # stays `latest.ver` for main (the m4 `include({{latest.ver}})` + # token), consumed by build-dockerreadme-task.yml. + name: versions-${{ inputs.branch }} + path: ${{ runner.temp }}/versions/${{ inputs.branch == 'main' && 'latest.ver' || 'develop.ver' }} diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index c8b522cb..500f11a5 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -2,6 +2,15 @@ name: Get version information task on: workflow_call: + inputs: + # Git ref to check out / version (empty = caller's default checkout ref, + # `github.ref`). The publisher passes an explicit branch so a scheduled + # run — whose `github.ref` is always the default branch — can still + # compute NBGV versions for `develop` too. + ref: + required: false + type: string + default: '' outputs: # Version information outputs SemVer2: @@ -12,6 +21,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,19 +37,21 @@ jobs: 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@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: ${{ inputs.ref }} fetch-depth: 0 - name: Run Nerdbank.GitVersioning tool step id: nbgv - uses: dotnet/nbgv@3cf2d96c2aa00675081b59f401356ac1fb81092f # v0.5.1 + uses: dotnet/nbgv@705dad19ab067f12f4e9eeaa60812e01edef5d25 # v0.5.2 diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index d2f3f73f..6f596abb 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -31,10 +31,12 @@ jobs: # committed by the App and fires downstream workflows on develop/main. # Pushes from GITHUB_TOKEN are blocked from triggering further workflow # runs by GitHub's recursion guard, which would silently skip - # publish-release.yml and publish-periodic-docker-release.yml on the - # merge commit and prevent develop's auto-prerelease/Docker rebuild. + # publish-release.yml on the merge commit. Under the default two-phase + # model that push is a no-op publish (it only republishes when the + # `PUBLISH_ON_MERGE` repo variable is `true`), but the App token keeps + # that opt-in path — and any future push-triggered workflow — working. id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/publish-periodic-docker-release.yml b/.github/workflows/publish-periodic-docker-release.yml deleted file mode 100644 index 5651069d..00000000 --- a/.github/workflows/publish-periodic-docker-release.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Publish weekly Docker image to Docker Hub action - -on: - push: - branches: [ main, develop ] - 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 - - tool-version-matrix: - name: Get tool versions - runs-on: ubuntu-latest - needs: build-docker - strategy: - matrix: - include: - - tag: ${{ github.ref_name == 'main' && 'latest' || 'develop' }} - file: latest.ver - - steps: - - - name: Get image size step - run: | - mkdir -p ${{ runner.temp }}/versions - touch ${{ runner.temp }}/versions/${{ matrix.file }} - echo Image: docker.io/ptr727/plexcleaner:${{ matrix.tag }} >> ${{ runner.temp }}/versions/${{ matrix.file }} - echo Size: $(docker manifest inspect -v docker.io/ptr727/plexcleaner:${{ matrix.tag }} | jq '.[] | select(.Descriptor.platform.architecture=="amd64") | [.OCIManifest.layers[].size] | add' | numfmt --to=iec) >> ${{ runner.temp }}/versions/${{ matrix.file }} - - - name: Write tool versions to file step - uses: addnab/docker-run-action@4f65fabd2431ebc8d299f8e5a018d79a769ae185 # v3 - with: - image: docker.io/ptr727/plexcleaner:${{ matrix.tag }} - options: --volume ${{ runner.temp }}/versions:/versions - run: | - echo OS: $(. /etc/os-release; echo $PRETTY_NAME) >> /versions/${{ matrix.file }} - echo dotNET: $(dotnet --info) >> /versions/${{ matrix.file }} - echo PlexCleaner: $(/PlexCleaner/PlexCleaner --version) >> /versions/${{ matrix.file }} - echo HandBrakeCLI: $(HandBrakeCLI --version) >> /versions/${{ matrix.file }} - echo MediaInfo: $(mediainfo --version) >> /versions/${{ matrix.file }} - echo MkvMerge: $(mkvmerge --version) >> /versions/${{ matrix.file }} - echo FfMpeg: $(ffmpeg -version) >> /versions/${{ matrix.file }} - - - name: Print versions step - run: cat ${{ runner.temp }}/versions/${{ matrix.file }} - - - name: Upload version artifacts step - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: versions-${{ matrix.file }} - path: ${{ runner.temp }}/versions/${{ matrix.file }} - - update-readme: - name: Create Docker README.md job - runs-on: ubuntu-latest - needs: tool-version-matrix - if: ${{ github.ref_name == 'main' }} - - steps: - - - name: Checkout step - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Download version artifacts step - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: versions-* - merge-multiple: true - path: ${{ runner.temp }}/versions - - - name: Create README.md from README.m4 step - run: m4 --include=${{ runner.temp }}/versions ./Docker/README.m4 > ${{ runner.temp }}/README.md - - - name: Update Docker Hub README.md step - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - repository: ptr727/plexcleaner - short-description: ${{ github.event.repository.description }} - readme-filepath: ${{ runner.temp }}/README.md - - 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 e9390888..2e559e30 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -4,21 +4,120 @@ 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' +# 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 }}-${{ github.ref }} - cancel-in-progress: true + 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) }} + # 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: - create-release: + 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" + + 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: - # Push to GitHub; Docker image is built (validates Dockerfile) but not pushed - # Docker Hub publishing is handled by publish-periodic-docker-release.yml + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub and Docker Hub. github: true - dockerhub: false + dockerhub: true + + tool-versions: + name: Get tool versions job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-toolversions-task.yml + secrets: inherit + with: + branch: ${{ matrix.branch }} + + docker-readme: + name: Create Docker README.md job + needs: [setup, tool-versions] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-dockerreadme-task.yml + secrets: inherit + with: + # The task self-gates to `main`; the develop leg is a no-op. + branch: ${{ matrix.branch }} + + 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/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 7322ef08..9621fcdb 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -11,26 +11,123 @@ concurrency: jobs: - test-release: - name: Test release job - uses: ./.github/workflows/test-release-task.yml + 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' }} + executable: ${{ github.event_name == 'pull_request' && steps.filter.outputs.executable || 'true' }} + steps: + # Build-workflow files are intentionally NOT in the filters — a path + # filter can't tell a logic change in a reusable workflow 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). On `workflow_dispatch` + # there is no PR base to diff, so the outputs default to `true`. + - 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/**' + - 'PlexCleaner/**' + executable: + - *shared + - 'PlexCleaner/**' + + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Check code style step + run: | + dotnet tool restore + dotnet husky install + dotnet husky run + + - name: Run unit tests step + run: dotnet test + + smoke-build: + name: Smoke build changed targets job + # 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.executable == 'true' + uses: ./.github/workflows/build-release-task.yml secrets: inherit + with: + smoke: true + # Do not publish anything from a PR. + github: 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. `workflow_dispatch` has no pull_request payload, + # so fall back to the triggering ref. Validate against the base branch's + # configuration (Release for main, Debug for develop). + 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_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. check-workflow-status: name: Check pull request workflow status runs-on: ubuntu-latest needs: - [ test-release ] + [ changes, unit-test, smoke-build ] if: always() steps: - - name: Check workflow results + - 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 }}" + # 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 6b5cfae3..00000000 --- a/.github/workflows/test-release-task.yml +++ /dev/null @@ -1,40 +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: Check code style step - run: | - dotnet tool restore - dotnet husky install - dotnet husky run - - - 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 - dockerhub: false diff --git a/AGENTS.md b/AGENTS.md index 815bd456..42b850f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,11 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ ## Git and Commit Rules -**These rules are absolute — no exceptions:** - -- **Never make git commits.** AI coding agents cannot produce cryptographically signed commits. All commits must be signed (SSH/GPG) and must be made by the developer. Stage changes with `git add` and leave the commit to the developer. +- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound — it covers the commits needed for that specific task, not a blanket commit license. +- **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration (`git config commit.gpgsign true`, a configured `user.signingkey`, and a loaded signing agent). If signing is not configured in the environment, **do not commit** — surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L`, or the GPG equivalent). - **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. - **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. -- **Staging is the limit.** Prepare and stage file changes; the developer runs `git commit` in their own environment where signing keys are available. +- **The `develop → main` release merge is maintainer-only.** An agent may drive `feature → develop` PRs end-to-end (commit, push, review loop, squash-merge) when authorized, but never self-merges a release to `main` — prepare it and hand it off. ## Branches and merging @@ -22,31 +21,46 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ Repo settings reflect this: `allow_merge_commit=true`, `allow_squash_merge=true`, `allow_rebase_merge=false`, `allow_auto_merge=true`. The `develop` ruleset enforces `allowed_merge_methods=["squash"]` and `required_linear_history`. The `main` ruleset enforces `allowed_merge_methods=["merge"]` and intentionally omits linear-history (the develop → main merge commit is non-linear by design). +## Merging a PR + +**Never merge a PR without `copilot-pull-request-reviewer[bot]` (shown as "Copilot" in the GitHub UI; the `[bot]` suffix is its actual login) having posted a clean re-review on the latest commit** — defined as a review whose `commit_id` (or GraphQL `commit.oid`) equals the PR's `headRefOid`, with no new unresolved inline threads (Copilot in this repo posts `COMMENTED` reviews, not `APPROVED`, so a clean COMMENTED review with zero open threads is the "no issues found" outcome). `mergeStateStatus: CLEAN` only confirms ruleset gates (thread resolution, status checks, signatures); it does not confirm Copilot has re-evaluated the latest changes. + +After resolving Copilot's threads or pushing fixes: + +1. **Re-request a Copilot review programmatically** on the current head via the GraphQL `requestReviews` mutation — auto-review-on-push fires inconsistently, so don't wait on it. The full mechanics (bot node-id discovery, the mutation, head-SHA coverage check, thread reply/resolve, bounded retry) are in the [GitHub Copilot Review Runbook](./.github/copilot-instructions.md#github-copilot-review-runbook). The agent can drive this loop end-to-end without a maintainer clicking "re-request review" in the UI. +2. Verify Copilot's most recent review targets the current head — compare its `commit.oid`/`commit_id` to `headRefOid`, not timestamps (multiple reviews and authors clutter the list, and timestamp drift is unreliable). +3. If the fresh review is `COMMENTED` with zero unresolved inline threads (or `APPROVED`), the PR is good to merge. +4. If the fresh review introduces new concerns (inline threads or body-level objections), address them and loop. +5. **If Copilot does not re-review within a reasonable window (~5 min) after re-requesting**, retry per the runbook's bounded-retry workflow (up to three total); if still missing, mark the review blocked and escalate to the maintainer. Silence is not approval. + +This applies to every human-authored PR (feature → develop, develop → main). The merge-bot workflow's auto-merge of dependabot bumps is the only exception and is governed separately by the `update-type` filter. + ## Develop → Main Promotion Use the **"Create a merge commit"** option on develop → main PRs. Repo rulesets are split: PRs into `develop` are squash-only (linear history); PRs into `main` are merge-commit only. Clicking "Create a merge commit" on a develop → main PR produces a merge commit on main whose second parent is develop's tip — so develop becomes a real ancestor of main, and the *next* develop → main PR has a clean merge base (no recurring conflicts, no behind-base churn). Under any squash-only setup this would be a recurring pain point: each develop → main squash drops develop's ancestry and forces a per-cycle admin-bypass merge commit on develop to resync. With merge-commit on main, that resync is unnecessary — main's history shows one merge commit per release (a feature, not a defect: each promotion is visible as a single auditable node), and develop stays linear. +**Immediately after a develop → main merge lands and main's publish workflows complete, bump the minor version in [version.json](version.json) on develop.** Open a small isolated feature PR `bump-version-X.Y` (e.g. `"version": "3.16"` → `"version": "3.17"`), squash into develop, and continue feature work from there. Without this bump, develop's next NBGV-computed prerelease (`3.16.-g{sha}`) is *numerically lower* than the stable that just shipped (`3.16.`), which is visibly confusing in HISTORY.md, `--version` output, and consumer update prompts. Bumping ensures every develop prerelease is `3.17.-g{sha}` — visibly newer than main's `3.16.`. Don't bundle the bump with other work; keep the PR isolated so the version change is unambiguous in git blame. + ## Release flow -PlexCleaner is a "pull" project: consumers (`docker pull ptr727/plexcleaner:latest`, `docker pull ptr727/plexcleaner:develop`, GitHub Releases) track both branches. **Both `main` and `develop` auto-publish on every push** — there is no manual `workflow_dispatch` gate. +PlexCleaner is a "pull" project: consumers (`docker pull ptr727/plexcleaner:latest`, `docker pull ptr727/plexcleaner:develop`, GitHub Releases) track both branches. It uses a **two-phase model** that decouples merging from publishing: -[publish-release.yml](.github/workflows/publish-release.yml) drives both prereleases and stable releases off the same [build-release-task.yml](.github/workflows/build-release-task.yml). It triggers on `push: [main, develop]`: +- **PRs smoke-test only.** [test-pull-request.yml](.github/workflows/test-pull-request.yml) always runs unit tests, then a [`dorny/paths-filter`](.github/workflows/test-pull-request.yml) `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a `linux-x64` + `win-x64` 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. There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. 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. +- **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 multi-arch build/publish of **both** `main` and `develop` (a branch matrix in one run). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in legacy continuous-release). Unset/`false` = two-phase: routine merges to `develop`/`main` only smoke-build, and `:latest`/`:develop` Docker tags + GitHub releases refresh on the weekly run instead of on every merge. -- **Push to `develop`** — automatic prerelease. Merging any PR into `develop` (feature, bug fix, dependabot) calls [get-version-task.yml](.github/workflows/get-version-task.yml) for an NBGV-computed version like `3.16.42-g1a2b3c4` (because develop does not match `publicReleaseRefSpec` in [version.json](version.json)) and creates a GitHub Release with `prerelease: true`. The Docker image is tagged `develop` by [publish-periodic-docker-release.yml](.github/workflows/publish-periodic-docker-release.yml). -- **Push to `main`** — automatic stable release. NBGV produces a clean version like `3.16.42` and creates a GitHub Release with `prerelease: false`. The Docker image is tagged `latest`. +A `setup` job computes the plan: `push` ⇒ the pushed branch with `publish = (vars.PUBLISH_ON_MERGE == 'true')`; `schedule`/`dispatch` ⇒ both branches with `publish = true`. The `publish` job is a `matrix.branch` fan-out over [build-release-task.yml](.github/workflows/build-release-task.yml); `tool-versions`, `docker-readme` (main only), and `date-badge` (main only) run after it. -Branch-aware logic lives in three places: +Branch-aware config keys off the **`branch` input** threaded through every reusable task — **never `github.ref_name`** (the publisher builds `develop` from a run whose `github.ref_name` is `main`, so a fallback would mislabel it). `main` ⇒ Release / `latest` / stable release; anything else ⇒ Debug / `develop` / prerelease. The GitHub release's `target_commitish` is pinned to NBGV's `GitCommitId` (the exact built commit) — not `github.sha` (wrong on the develop leg) and not a branch name (a moving ref); `get-version-task.yml` surfaces `GitCommitId` as an output. The release step is skipped when a release for the computed `SemVer2` tag already exists (no-op weekly republish), except on `workflow_dispatch` (which can refresh a partial release). -- [build-release-task.yml](.github/workflows/build-release-task.yml) — `prerelease: ${{ github.ref_name != 'main' }}` and `target_commitish: ${{ github.sha }}` (the latter is critical: without it, softprops creates the tag against the repo's default branch, mis-tagging develop builds onto main's tip). -- [publish-periodic-docker-release.yml](.github/workflows/publish-periodic-docker-release.yml) — `tag: ${{ github.ref_name == 'main' && 'latest' || 'develop' }}`. +**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`, and its path-filter entry in `test-pull-request.yml`. Versioning, badge, tool-versions, Docker README, merge-bot, and Dependabot are target-agnostic. PlexCleaner's targets are the **Docker image** and the **console executable** only (no NuGet/PyPI). -Bot-merged PRs (Dependabot) trigger the publish workflows automatically because the merge-bot uses an App token — see the merge-bot section below. +Bot-merged PRs (Dependabot) still trigger `publish-release.yml` because the merge-bot uses an App token (see the merge-bot section) — under the default two-phase model that push run is a no-op publish unless `PUBLISH_ON_MERGE` is set. ## Dependabot -[.github/dependabot.yml](.github/dependabot.yml) targets **both `main` and `develop`** with two ecosystems each (`nuget`, `github-actions`), grouped per ecosystem, daily. The duplication is intentional: because both branches auto-publish, develop must not drift from main's dependency baseline. A NuGet major bump landing on develop should land on main on the next promotion cycle, not weeks later. +[.github/dependabot.yml](.github/dependabot.yml) targets **both `main` and `develop`** with two ecosystems each (`nuget`, `github-actions`), grouped per ecosystem, daily. The duplication is intentional: both branches ship from the weekly publisher (and on every merge when `PUBLISH_ON_MERGE` is set), so develop must not drift from main's dependency baseline. A NuGet major bump landing on develop should land on main on the next promotion cycle, not weeks later. Major NuGet bumps are not auto-merged by [merge-bot-pull-request.yml](.github/workflows/merge-bot-pull-request.yml) — they require human review. Major GitHub Actions bumps are auto-merged because the workflow execution itself is the validation surface. @@ -63,7 +77,7 @@ When adding a new `uses:` line, resolve the latest release's commit SHA (`gh api [merge-bot-pull-request.yml](.github/workflows/merge-bot-pull-request.yml) auto-merges Dependabot PRs. Two key design choices: - **Branch-aware merge method**: the script picks `--squash` for PRs targeting develop and `--merge` for PRs targeting main, matching each ruleset's `allowed_merge_methods`. An unknown base branch is a hard error. -- **App token, not GITHUB_TOKEN**: the merge step uses a token minted by `actions/create-github-app-token` from `CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` secrets. Pushes authored by `GITHUB_TOKEN` are blocked from triggering downstream workflows by GitHub's recursion guard; without the App token, a Dependabot merge to develop would silently skip `publish-release.yml` and `publish-periodic-docker-release.yml`, leaving the develop Docker tag and prerelease stale. +- **App token, not GITHUB_TOKEN**: the merge step uses a token minted by `actions/create-github-app-token` from `CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` secrets. Pushes authored by `GITHUB_TOKEN` are blocked from triggering downstream workflows by GitHub's recursion guard; without the App token, a Dependabot merge would silently skip `publish-release.yml` on the merge commit. Under the default two-phase model that push is a no-op publish (it only republishes when `PUBLISH_ON_MERGE` is `true`), but the App token keeps that opt-in path — and any future push-triggered workflow — working. The App secrets (`CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY`) must exist in **both** secret namespaces: Settings → Secrets and variables → **Actions**, and Settings → Secrets and variables → **Dependabot**. Since Sept 2021, GitHub injects only the Dependabot-namespace secrets when a Dependabot-authored `pull_request` event fires; the regular Actions namespace is not visible to that run. Without the Dependabot duplicate the App-token step gets empty inputs and merge-bot silently fails to auto-merge. (The trigger remains `pull_request`, not `pull_request_target` — the merge-bot doesn't check out PR code, but `pull_request` plus duplicated secrets is the simpler, less-permissive setup.) diff --git a/Directory.Packages.props b/Directory.Packages.props index c8dc0620..b17e5330 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,18 +1,18 @@ - - + + - - - + + + - + diff --git a/README.md b/README.md index 4d13a6d5..a50906fc 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Choose an installation method based on your platform and requirements: - See the [Docker README][docker-link] for current distribution and media tool versions. - `ptr727/plexcleaner:latest` is based on [Ubuntu][ubuntu-hub-link] (`ubuntu:rolling`) built from the `main` branch. - `ptr727/plexcleaner:develop` is based on [Ubuntu][ubuntu-hub-link] (`ubuntu:rolling`) built from the `develop` branch. -- Images are updated weekly with the latest upstream updates. +- Docker images and GitHub Releases for both branches are published on a weekly schedule (and on demand), each refreshed with the latest upstream and base-image updates. Pull requests are smoke-tested only and do not publish. - The container has all the prerequisite 3rd party tools pre-installed. **Path Mapping Convention**: All examples use `/data/media` as the host path mapped to `/media` inside the container. Replace `/data/media` with your actual host media location. @@ -990,7 +990,7 @@ Licensed under the [MIT License][license-link]\ [docker-develop-version-shield]: https://img.shields.io/docker/v/ptr727/plexcleaner/develop?label=Docker%20Develop&logo=docker&color=orange [docker-latest-version-shield]: https://img.shields.io/docker/v/ptr727/plexcleaner/latest?label=Docker%20Latest&logo=docker [docker-link]: https://hub.docker.com/r/ptr727/plexcleaner -[docker-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/PlexCleaner/publish-periodic-docker-release.yml?logo=github&label=Docker%20Build +[docker-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/PlexCleaner/publish-release.yml?event=schedule&logo=github&label=Docker%20Build [github-link]: https://github.com/ptr727/PlexCleaner [plexcleaner-hub-link]: https://hub.docker.com/r/ptr727/plexcleaner [issues-link]: https://github.com/ptr727/PlexCleaner/issues @@ -1000,7 +1000,7 @@ Licensed under the [MIT License][license-link]\ [license-link]: ./LICENSE [license-shield]: https://img.shields.io/github/license/ptr727/PlexCleaner?label=License [pre-release-version-shield]: https://img.shields.io/github/v/release/ptr727/PlexCleaner?include_prereleases&label=GitHub%20Pre-Release&logo=github -[release-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/PlexCleaner/publish-release.yml?logo=github&label=Releases%20Build +[release-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/PlexCleaner/publish-release.yml?event=schedule&logo=github&label=Releases%20Build [release-version-shield]: https://img.shields.io/github/v/release/ptr727/PlexCleaner?logo=github&label=GitHub%20Release [releases-link]: https://github.com/ptr727/PlexCleaner/releases [ubuntu-hub-link]: https://hub.docker.com/_/ubuntu diff --git a/version.json b/version.json index a79bea72..746ac17f 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.16", + "version": "3.17", "publicReleaseRefSpec": [ "^refs/heads/main$" ],