diff --git a/.editorconfig b/.editorconfig index d66b1947..31ddc559 100644 --- a/.editorconfig +++ b/.editorconfig @@ -44,6 +44,10 @@ end_of_line = crlf [*.sh] end_of_line = lf +# Dockerfiles - CRLF breaks RUN heredocs and line continuations +[{Dockerfile,*.Dockerfile}] +end_of_line = lf + # Windows scripts [*.{cmd,bat,ps1}] end_of_line = crlf @@ -60,6 +64,8 @@ end_of_line = crlf dotnet_diagnostic.IDE0055.severity = none dotnet_analyzer_diagnostic.severity = suggestion +# PlexCleaner adaptation: repo-wide CA relaxations that apply to every project here (a console app, not a +# reusable library). Per CODESTYLE.md these are justified repo-wide because they hold for all projects. # Public API surface rules: not applicable, this is a console app not a reusable library dotnet_diagnostic.CA1002.severity = none # Do not expose List dotnet_diagnostic.CA1024.severity = none # Use properties where appropriate diff --git a/.gitattributes b/.gitattributes index d4823c75..7e5e03e8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,20 @@ -# Leave line endings alone +# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes. +# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor. # git config --global core.autocrlf false # git add --renormalize . # git ls-files --eol * -text + +# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig` +# covers `*.sh`, but extensionless executables match no extension rule, so pin them here so git enforces LF on checkout +# and `--renormalize`. Any repo whose tooling ships extensionless scripts adds the matching path pin, e.g. s6-overlay +# init `Docker/s6-overlay/** text eol=lf` or husky/git hooks `.husky/pre-commit text eol=lf`. +*.sh text eol=lf +.husky/pre-commit text eol=lf + +# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations. +Dockerfile text eol=lf +*.Dockerfile text eol=lf + +# m4 README source is rendered on Linux; keep it LF. +Docker/README.m4 text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 152efecb..cc83443e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,43 +1,43 @@ -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -version: 2 -updates: - - # main - - package-ecosystem: "nuget" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - package-ecosystem: "github-actions" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - # develop - - package-ecosystem: "nuget" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - nuget-deps: - patterns: - - "*" - - package-ecosystem: "github-actions" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + + # main + - package-ecosystem: "nuget" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + # develop + - package-ecosystem: "nuget" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + nuget-deps: + patterns: + - "*" + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index 437879a6..0624ebfe 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -1,35 +1,29 @@ -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: - - date-badge: - name: Build BYOB date badge job - runs-on: ubuntu-latest - - steps: - - - name: Get current date step - id: date - run: echo "date=$(date)" >> $GITHUB_OUTPUT - - - name: Build BYOB date badge step - if: ${{ inputs.branch == 'main' }} - uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 - with: - name: lastbuild - label: "Last Build" - icon: "github" - status: ${{ steps.date.outputs.date }} - color: "blue" - github_token: ${{ secrets.GITHUB_TOKEN }} +name: Build BYOB date badge task + +# Caller-gated: the publisher invokes this only when main is published - the badge has no per-branch context, it tracks +# the last main build. + +on: + workflow_call: + +jobs: + + date-badge: + name: Build BYOB date badge job + runs-on: ubuntu-latest + + steps: + + - name: Get current date step + id: date + run: echo "date=$(date)" >> "$GITHUB_OUTPUT" + + - name: Build BYOB date badge step + uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 + with: + name: lastbuild + label: "Last Build" + icon: "github" + status: ${{ steps.date.outputs.date }} + color: "blue" + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index fa4fd42c..3a37b525 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -1,101 +1,101 @@ -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 - -jobs: - - get-version: - name: Get version information job - uses: ./.github/workflows/get-version-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - - build-executable-matrix: - name: Build executable project matrix job - runs-on: ubuntu-latest - needs: [get-version] - strategy: - matrix: - runtime: ${{ 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@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - 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 ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ - -property:PublishAot=false \ - -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ - -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ - -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \ - -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ - -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} - - - name: Upload matrix build artifacts step - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: publish-${{ inputs.branch }}-${{ matrix.runtime }} - path: ${{ runner.temp }}/publish - retention-days: 1 - - # 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 release job collects the - # release-asset--* artifacts by pattern, so no artifact-id is needed. - 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 }}/PlexCleaner.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 }}/PlexCleaner.7z - retention-days: 1 +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 + +jobs: + + get-version: + name: Get version information job + uses: ./.github/workflows/get-version-task.yml + secrets: inherit + with: + ref: ${{ inputs.ref }} + + build-executable-matrix: + name: Build executable project matrix job + runs-on: ubuntu-latest + needs: [get-version] + strategy: + matrix: + runtime: ${{ 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@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + 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 ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ + -property:PublishAot=false \ + -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ + -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ + -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} \ + -property:InformationalVersion=${{ needs.get-version.outputs.AssemblyInformationalVersion }} \ + -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} + + - name: Upload matrix build artifacts step + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publish-${{ inputs.branch }}-${{ matrix.runtime }} + path: ${{ runner.temp }}/publish + retention-days: 1 + + # 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 release job collects the + # release-asset--* artifacts by pattern, so no artifact-id is needed. + 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 }}/PlexCleaner.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 }}/PlexCleaner.7z + retention-days: 1 diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 84a5e87b..2b330aeb 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -1,174 +1,167 @@ -name: Build project release task - -on: - workflow_call: - inputs: - # Input to control whether to create a GitHub release - github: - required: false - type: boolean - default: false - # Input to control whether to push the docker image to Docker Hub - 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 / 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: - - get-version: - 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: - # 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 - # `&& !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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ needs.get-version.outputs.GitCommitId }} - - # Safety net: a public (main) release must never carry a prerelease identifier - any '-' in SemVer2 (e.g. the - # observed '-g' git-height suffix). Guards against NBGV mis-detecting the public ref (e.g. a dispatch on a - # non-default ref) from publishing a malformed non-prerelease release that GitHub would then mark "Latest". - # Root-cause-agnostic backstop to the dispatch-ref guard in publish-release.yml. - - name: Verify public release version step - if: ${{ inputs.branch == 'main' }} - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - if [[ "$SEMVER2" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - - # Collect every release-asset--* artifact by pattern, so the - # release job never names a build job and stays reusable as targets change. - - name: Download release asset artifacts step - 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 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@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 - with: - tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ needs.get-version.outputs.GitCommitId }} - prerelease: ${{ inputs.branch != 'main' }} - generate_release_notes: true - fail_on_unmatched_files: true - files: | - LICENSE - README.md - ./Publish/* +name: Build project release task + +on: + workflow_call: + inputs: + # Whether to create a GitHub release. + github: + required: false + type: boolean + default: false + # Whether to push the docker image to Docker Hub. + 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 / prerelease for every target. Required (no fallback) because the + # publisher builds both `main` and `develop` in one run, so a silent fallback would mislabel the develop leg. + branch: + required: true + type: string + # Smoke mode: reduced, never-published build for fast PR feedback. Forwarded to every target and 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. + enable_docker: + required: false + type: boolean + default: true + enable_executable: + required: false + type: boolean + default: true + # 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: + + get-version: + 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 resolved commit so the artifacts match the release tag. + 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: + # Pin to the resolved commit so the artifacts match the release tag. + ref: ${{ needs.get-version.outputs.GitCommitId }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} + # 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, 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} + + # Backstop (main only): a public release must not carry a prerelease '-', guarding against NBGV mis-versioning the + # public ref (e.g. a dispatch on a non-default ref) into a malformed "Latest" release. Strip '+buildmetadata' + # first - a '-' there is legitimate; only a '-' in the core/prerelease segment marks a prerelease. + - name: Verify public release version step + if: ${{ inputs.branch == 'main' }} + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + + # Collect assets by the `release-asset--*` pattern so this step is target-agnostic: subset releases by + # deleting the target, not `enable_*: false` (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 (e.g. a Docker-only repo, whose release carries only source zip + README + LICENSE) + # relaxes that guard. + - 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 -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: 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 (any branch, 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 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@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + 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/* diff --git a/.github/workflows/build-toolversions-task.yml b/.github/workflows/build-toolversions-task.yml index c841b682..1a726ba2 100644 --- a/.github/workflows/build-toolversions-task.yml +++ b/.github/workflows/build-toolversions-task.yml @@ -1,75 +1,76 @@ -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 publish-docker-readme-task.yml. - name: versions-${{ inputs.branch }} - path: ${{ runner.temp }}/versions/${{ inputs.branch == 'main' && 'latest.ver' || 'develop.ver' }} - retention-days: 1 +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: >> /versions/$FILE + 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 publish-docker-readme-task.yml. + name: versions-${{ inputs.branch }} + path: ${{ runner.temp }}/versions/${{ inputs.branch == 'main' && 'latest.ver' || 'develop.ver' }} + retention-days: 1 diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 584193e9..46df0c77 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -1,57 +1,57 @@ -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: - 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 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: - - 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@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - - - name: Run Nerdbank.GitVersioning tool step - id: nbgv - uses: dotnet/nbgv@705dad19ab067f12f4e9eeaa60812e01edef5d25 # v0.5.2 +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: + 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 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: + + 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@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Run Nerdbank.GitVersioning tool step + id: nbgv + 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 6f596abb..e23a093a 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,76 +1,97 @@ -name: Merge bot pull request action - -"on": - pull_request: - types: [opened, reopened, synchronize] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - merge-dependabot: - name: Merge dependabot pull request job - runs-on: ubuntu-latest - # Restrict to dependabot PRs that originate from this repository, not a - # fork. Check the PR author rather than the event actor so maintainer - # repair commits on Dependabot branches can still auto-merge after CI - # passes. - if: >- - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - # Use an App token (not GITHUB_TOKEN) so the resulting merge push is - # 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 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Get dependabot metadata step - id: metadata - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - # Skip semver-major NuGet bumps: they often build cleanly but break - # runtime behavior, so they should land via human review. GitHub Actions - # majors auto-merge because the workflow execution itself validates them. - # - # Merge method must match the base branch's ruleset: - # develop -> squash only (linear history) - # main -> merge commits only (preserves develop ancestry) - # A mismatch fails enablePullRequestAutoMerge with - # "Merge method ... is not allowed on this repository". - - name: Merge pull request step - if: >- - (steps.metadata.outputs.package-ecosystem != 'nuget') || - (steps.metadata.outputs.update-type != 'version-update:semver-major') - run: | - set -euo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} +name: Merge bot pull request action + +# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge +# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows +# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs. + +# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and +# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR +# code - each only runs `gh pr merge` against the PR by URL. +on: + pull_request_target: + types: [opened, reopened, synchronize] + +# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR +# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a +# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + + merge-dependabot: + name: Merge dependabot pull request job + runs-on: ubuntu-latest + # Dependabot PRs from this repo (not forks). Only on opened/reopened so the disable job stays sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + 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 }} + + - name: Get dependabot metadata step + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # Skip semver-major NuGet bumps so they land via human review; other ecosystems' majors auto-merge. + - name: Merge pull request step + if: >- + (steps.metadata.outputs.package-ecosystem != 'nuget') || + (steps.metadata.outputs.update-type != 'version-update:semver-major') + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + disable-auto-merge-on-maintainer-push: + name: Disable auto-merge on maintainer push job + runs-on: ubuntu-latest + # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the + # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.actor != github.event.pull_request.user.login + permissions: + pull-requests: write + + steps: + + - name: Generate GitHub App token step + # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. + id: app-token + 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 }} + + - name: Disable auto-merge step + run: gh pr merge --disable-auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml index 242d5bb9..2a3fb3f8 100644 --- a/.github/workflows/publish-docker-readme-task.yml +++ b/.github/workflows/publish-docker-readme-task.yml @@ -1,54 +1,126 @@ -name: Publish Docker Hub readme 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: Publish Docker Hub readme 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - 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 +name: Publish Docker Hub readme task + +# Pushes the Docker Hub repository overview (Docker/README.md). Caller-gated: the publisher invokes this only when main +# is published - the overview has no per-branch context. The repository list is either passed directly (`repositories`) +# or derived from a manifest (`manifest` + `manifest-jq`), covering single- and multi-image repos without per-repo glue. +# An optional transform step (e.g. m4) renders the readme before pushing, optionally after downloading a build artifact. + +on: + workflow_call: + inputs: + # Ref whose readme (and any transform sources) to publish; empty uses the caller's ref. The publisher passes + # `main` so the overview tracks the main release even when dispatched from another ref. + ref: + required: false + type: string + default: '' + # JSON array of Docker Hub repositories to update, e.g. '["owner/image"]'. Leave empty to derive the list from a + # manifest instead (see `manifest`). + repositories: + required: false + type: string + default: '' + # Optional: derive the repository list from a checked-out manifest file (used when `repositories` is empty), e.g. + # './Make/Matrix.json'. `manifest-jq` is the jq program that turns it into the JSON array of repo names. + manifest: + required: false + type: string + default: '' + manifest-jq: + required: false + type: string + default: '' + # Optional command that renders the readme before pushing (e.g. an m4 step that writes Docker/README.md). Empty + # pushes the committed file as-is. + transform-run: + required: false + type: string + default: '' + # Optional same-run workflow artifact to download before the transform (e.g. version includes the m4 render needs). + # Using this requires the caller to grant `permissions: actions: read` so download-artifact can read the run's artifacts. + transform-artifact: + required: false + type: string + default: '' + # Readme file to push (the transform's output, or the committed file). Single-image repos with a root README pass + # './README.md'. + readme-filepath: + required: false + type: string + default: ./Docker/README.md + +jobs: + + # Resolve the repository list once - either the static `repositories` input or a jq program over a manifest - so the + # publish matrix is the same shape for single- and multi-image repos and no caller hand-rolls its own derivation. + get-repos: + name: Get repository list job + runs-on: ubuntu-latest + outputs: + repositories: ${{ steps.list.outputs.repositories }} + + steps: + + - name: Checkout code step + # Only the manifest path needs the tree; the static + default paths don't check out. + if: ${{ inputs.repositories == '' && inputs.manifest != '' }} + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve repository list step + id: list + env: + REPOSITORIES: ${{ inputs.repositories }} + MANIFEST: ${{ inputs.manifest }} + MANIFEST_JQ: ${{ inputs.manifest-jq }} + run: | + set -euo pipefail + if [ -n "$REPOSITORIES" ]; then + echo "repositories=$REPOSITORIES" >> "$GITHUB_OUTPUT" + elif [ -n "$MANIFEST" ] && [ -n "$MANIFEST_JQ" ]; then + echo "repositories=$(jq --compact-output "$MANIFEST_JQ" "$MANIFEST")" >> "$GITHUB_OUTPUT" + else + # Default to this repo's own Docker Hub repository (lowercased owner/name) so a single-image caller + # carries the orchestration verbatim with no repo-specific value. + echo "repositories=[\"$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')\"]" >> "$GITHUB_OUTPUT" + fi + + publish-readme: + name: Publish Docker Hub readme job + runs-on: ubuntu-latest + needs: get-repos + strategy: + matrix: + repository: ${{ fromJSON(needs.get-repos.outputs.repositories) }} + + steps: + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref || github.ref }} + + # Optional: pull a same-run artifact the transform depends on (e.g. version includes for an m4 render). + - name: Download transform artifact step + if: ${{ inputs.transform-artifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.transform-artifact }} + path: ${{ runner.temp }}/transform + + # Optional readme render (e.g. m4 with version includes) before pushing. + - name: Generate readme step + if: ${{ inputs.transform-run != '' }} + run: | + set -euo pipefail + ${{ inputs.transform-run }} + + - name: Publish Docker Hub readme step + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + repository: ${{ matrix.repository }} + short-description: ${{ github.event.repository.description }} + readme-filepath: ${{ inputs.readme-filepath }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index d2b7ea02..e139b2e9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,132 +1,161 @@ -name: Publish project release action - -on: - push: - branches: [ main, develop ] - workflow_dispatch: - schedule: - # Weekly full build/publish of both branches on Mondays at 02:00 UTC. - # This is the guaranteed publisher in the default two-phase model: routine - # merges only smoke-test, and this scheduled run republishes everything - # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). - - cron: '0 2 * * MON' - -# 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.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: - - 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 - # A schedule or dispatch builds BOTH branches in the matrix regardless of the triggering ref (a push builds - # only the pushed branch). Dispatching on a non-default ref makes the main leg mis-version (NBGV can't resolve - # the public main ref), publishing a malformed non-prerelease release that GitHub marks "Latest". Fail fast: - # dispatch only from the default branch. - if [[ "${{ github.event_name }}" == "workflow_dispatch" \ - && "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]]; then - echo "::error::Dispatch publish-release from the default branch (${{ github.event.repository.default_branch }}); the matrix builds both branches. Re-dispatch on the default branch." - exit 1 - fi - 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: - ref: ${{ matrix.branch }} - branch: ${{ matrix.branch }} - smoke: false - # Push to GitHub and Docker Hub. - github: true - 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: Publish Docker Hub readme job - needs: [setup, tool-versions] - if: ${{ needs.setup.outputs.publish == 'true' }} - strategy: - matrix: - branch: ${{ fromJSON(needs.setup.outputs.branches) }} - uses: ./.github/workflows/publish-docker-readme-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 }} +name: Publish project release action + +on: + push: + branches: [ main, develop ] + workflow_dispatch: + schedule: + # Weekly full build/publish of both branches on Mondays at 02:00 UTC. + # This is the guaranteed publisher in the default two-phase model: routine + # merges only smoke-test, and this scheduled run republishes everything + # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). + - cron: '0 2 * * MON' + +# 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.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: + + 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 + # A schedule or dispatch builds BOTH branches in the matrix regardless of the triggering ref (a push builds + # only the pushed branch). Dispatching on a non-default ref makes the main leg mis-version (NBGV can't resolve + # the public main ref), publishing a malformed non-prerelease release that GitHub marks "Latest". Fail fast: + # dispatch only from the default branch. + if [[ "${{ github.event_name }}" == "workflow_dispatch" \ + && "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]]; then + echo "::error::Dispatch publish-release from the default branch (${{ github.event.repository.default_branch }}); the matrix builds both branches. Re-dispatch on the default branch." + exit 1 + fi + 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: + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub and Docker Hub. + github: true + 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: Publish Docker Hub readme job + needs: [setup, tool-versions] + # Caller-gated to main: there is one Docker Hub README and the m4 template embeds the `latest` image's tool + # versions. The task is no longer self-gating, so gate here and render only when the matrix includes main. + if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} + uses: ./.github/workflows/publish-docker-readme-task.yml + secrets: inherit + permissions: + contents: read + actions: read + with: + ref: main + repositories: '["ptr727/plexcleaner"]' + # Download the main leg's tool-versions artifact (file `latest.ver`) to $RUNNER_TEMP/transform, then render + # Docker/README.md from the m4 template whose `include({{latest.ver}})` resolves against that include dir. + transform-artifact: versions-main + transform-run: m4 --include=$RUNNER_TEMP/transform ./Docker/README.m4 > ./Docker/README.md + readme-filepath: ./Docker/README.md + + # Caller-gated to main: the badge has no per-branch context, so it updates only when main is published. + date-badge: + name: Create BYOB date badge job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' && contains(fromJSON(needs.setup.outputs.branches), 'main') }} + uses: ./.github/workflows/build-datebadge-task.yml + secrets: inherit + permissions: + contents: write + + # Release artifacts are an intra-run handoff (durable copies live on the GitHub release), so leaving them accumulates + # against the small account-wide storage quota; delete them once every consumer has read them. + cleanup-artifacts: + name: Delete workflow artifacts job + needs: [setup, publish, tool-versions, docker-readme, date-badge] + if: ${{ always() && needs.setup.outputs.publish == 'true' }} + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Delete workflow artifacts step + # continue-on-error: best-effort housekeeping must never red the run, even on an unexpected failure. + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! ids=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts --paginate \ + --jq '.artifacts[].id'); then + echo "::warning::Could not list run artifacts; skipping cleanup (storage may not be freed)." + ids="" + fi + for artifact_id in $ids; do + gh api --method DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" \ + || echo "::warning::Failed to delete artifact $artifact_id; continuing." + done diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 881a9b4a..7a542f57 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,133 +1,133 @@ -name: Test pull request action - -on: - pull_request: - branches: [ main, develop ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - 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: - [ changes, unit-test, smoke-build ] - if: always() - steps: - - name: Check workflow results step - run: | - set -euo pipefail - exit_on_result() { - if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then - echo "Job '$1' failed or was cancelled." - exit 1 - fi - } - # The paths-filter job MUST succeed: if it failed we don't know which - # targets changed, so a target-changing PR could merge with its smoke - # build silently skipped. Treat anything other than success as a block. - if [[ "${{ needs.changes.result }}" != "success" ]]; then - echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." - exit 1 - fi - # unit-test always runs; smoke-build may be legitimately skipped - # (no target changed) — `skipped` passes, only failure/cancelled blocks. - exit_on_result "unit-test" "${{ needs.unit-test.result }}" - exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" +name: Test pull request action + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + 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@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - 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: + [ changes, unit-test, smoke-build ] + if: always() + steps: + - name: Check workflow results step + run: | + set -euo pipefail + exit_on_result() { + if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then + echo "Job '$1' failed or was cancelled." + exit 1 + fi + } + # The paths-filter job MUST succeed: if it failed we don't know which + # targets changed, so a target-changing PR could merge with its smoke + # build silently skipped. Treat anything other than success as a block. + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + exit 1 + fi + # unit-test always runs; smoke-build may be legitimately skipped + # (no target changed) - `skipped` passes, only failure/cancelled blocks. + exit_on_result "unit-test" "${{ needs.unit-test.result }}" + exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 009e6b3a..dbcd9602 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -15,7 +15,7 @@ ] }, { - "name": ".Net Format", + "name": ".NET Format", "command": "dotnet", "args": [ "format", diff --git a/.vscode/launch.json b/.vscode/launch.json index ead5d35b..2fc7133b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "name": "Help", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "--help", @@ -23,7 +23,7 @@ "name": "Process Help", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "process", @@ -37,7 +37,7 @@ "name": "Version", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "--version", @@ -50,7 +50,7 @@ "name": "Get Version Info", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "getversioninfo", @@ -64,7 +64,7 @@ "name": "Default Settings", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "defaultsettings", @@ -78,7 +78,7 @@ "name": "Process Single", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "process", @@ -96,7 +96,7 @@ "name": "Process Quickscan", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "process", @@ -116,7 +116,7 @@ "name": "Process TestSnippets Quickscan", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "process", @@ -137,7 +137,7 @@ "name": "Process Parallel TestSnippets Quickscan", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "process", @@ -160,7 +160,7 @@ "name": "Monitor", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "monitor", @@ -179,7 +179,7 @@ "name": "ReMux", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "remux", @@ -198,7 +198,7 @@ "name": "ReEncode", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "reencode", @@ -217,7 +217,7 @@ "name": "DeInterlace", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "deinterlace", @@ -236,7 +236,7 @@ "name": "Remove ClosedCaptions", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "removeclosedcaptions", @@ -255,7 +255,7 @@ "name": "Remove Subtitles", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "removesubtitles", @@ -273,7 +273,7 @@ "name": "Verify", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "verify", @@ -292,7 +292,7 @@ "name": "Get Tag Map", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "gettagmap", @@ -309,7 +309,7 @@ "name": "Get Media Info", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "getmediainfo", @@ -326,7 +326,7 @@ "name": "Test Media Info", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "testmediainfo", @@ -344,7 +344,7 @@ "name": "Get Tool Info", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "gettoolinfo", @@ -361,7 +361,7 @@ "name": "Check for new Tools", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "checkfornewtools", @@ -377,7 +377,7 @@ "name": "Create Sidecar", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "createsidecar", @@ -394,7 +394,7 @@ "name": "Update Sidecar", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "updatesidecar", @@ -411,7 +411,7 @@ "name": "Get Sidecar Info", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "getsidecarinfo", @@ -428,7 +428,7 @@ "name": "Create Schema", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/PlexCleaner/debug/PlexCleaner.dll", "args": [ "createschema", @@ -444,7 +444,7 @@ "name": "Sandbox", "type": "coreclr", "request": "launch", - "preLaunchTask": ".Net Build", + "preLaunchTask": ".NET Build", "program": "${workspaceFolder}/.artifacts/bin/Sandbox/debug/Sandbox.dll", "args": [], "cwd": "${workspaceFolder}/.artifacts/bin/Sandbox/debug", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 00b69b74..6614c4cd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": ".Net Build", + "label": ".NET Build", "type": "process", "command": "dotnet", "args": [ @@ -20,7 +20,7 @@ } }, { - "label": ".Net Format", + "label": ".NET Format", "type": "process", "command": "dotnet", "args": [ @@ -40,7 +40,7 @@ "dependsOrder": "sequence", "dependsOn": [ "CSharpier Format", - ".Net Build" + ".NET Build" ] }, { @@ -62,7 +62,7 @@ } }, { - "label": ".Net Tool Update", + "label": ".NET Tool Update", "type": "process", "command": "dotnet", "args": [ @@ -95,7 +95,7 @@ } }, { - "label": ".Net Outdated Upgrade", + "label": ".NET Outdated Upgrade", "type": "process", "command": "dotnet", "args": [ diff --git a/AGENTS.md b/AGENTS.md index 37b39ad7..e9e4fe5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ This file is the canonical reference for cross-cutting AI-agent and workflow rul - **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 working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). 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). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. - **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. + +### Git and Commit Rules - Repo-Specific Notes + - **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`. ## Branching Model @@ -22,7 +25,7 @@ This file is the canonical reference for cross-cutting AI-agent and workflow rul - *Main* - the check is graph-based; it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. - *Develop* - bot auto-merge incompatibility. When two bot PRs against develop land in the same minute (e.g. two grouped Dependabot PRs from the same daily run), the first to merge pushes the second into `mergeStateStatus: BEHIND`. GitHub's auto-merge will not fire while the strict flag is on, and nothing in the workflow set auto-updates a bot branch in that window - the merge-bot enables auto-merge via `gh pr merge --auto` but never rebases a stalled branch onto base (see [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)). Real file-level conflicts are still caught textually (`mergeable: CONFLICTING` blocks merge regardless); semantic-but-not-textual conflicts that combine cleanly are caught by the post-merge develop CI run rather than pre-merge. Do not reintroduce the strict flag on develop thinking it's hygiene - it breaks bot auto-merge. - **Dependabot targets both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch). Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). Parallel auto-merge across same-batch bot PRs is race-proof only because both rulesets have the strict "up to date" flag off (see bullet above). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. Semver-major NuGet bumps gate on human review; everything else auto-merges. -- **Maintainer repair commits on a Dependabot PR still auto-merge.** The merge-bot's `merge-dependabot` job gates on the PR *author* (`dependabot[bot]`), not the event actor, and fires on `opened` / `reopened` / `synchronize`, so a maintainer's fix-up commits pushed onto a Dependabot branch still auto-merge once CI passes (auto-merge is re-asserted idempotently via `gh pr merge --auto`). It runs only for Dependabot-authored PRs that originate from this repository, not forks. +- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` job only fires on `opened` / `reopened` events (auto-merge is enabled exactly once per PR, for Dependabot-authored PRs that originate from this repository, not forks). When a maintainer pushes commits to the bot's branch (a `synchronize` event with a non-bot actor), the `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`; the maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable manually (`gh pr merge --auto `) when ready. The merge-bot is on `pull_request_target` with per-PR concurrency; it carries only `merge-dependabot` + `disable-auto-merge-on-maintainer-push` (no `merge-codegen` / `merge-upstream-version` - this repo has neither codegen nor an upstream-version tracker). - **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the merge-bot uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` (with `private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}`). The App token - not `GITHUB_TOKEN` - is required so the merge push is committed by the App and fires downstream workflows (`GITHUB_TOKEN` pushes are blocked from triggering further runs by GitHub's recursion guard). When adding new App-token call sites, use the same form - do not reintroduce `app-id`. - **Why parallel dual-target rather than develop-only with eventual flow-through:** consumers pull the Docker image and the release executables from `main` directly. A develop-only model would leave `main` running stale code during long-running develop features, so both branches receive their own bot updates on their own cadence and each stays current. @@ -43,7 +46,7 @@ This repo uses a **two-phase model by default**: PRs build fast, publishing is b ### Format -- Imperative subject summarizing the change, <=72 characters, no trailing period. ("Add Direct Play seek-index verification", not "Added X" or "Adds X".) +- Imperative subject summarizing the change, <=72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) - Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. ### Rules @@ -51,7 +54,7 @@ This repo uses a **two-phase model by default**: PRs build fast, publishing is b - Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine - keep them.) - Don't add `Co-Authored-By:` lines unless the developer explicitly asks. - Don't put release-bump magnitude in the title - no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. -- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *Direct-Play*, *24-Hour*). +- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). ### Examples @@ -103,8 +106,23 @@ Applies to code and workflow (`#`) comments alike. ## PR Review Etiquette +> **Mandatory in every derived repo.** This entire "PR Review Etiquette" section is the provider-agnostic review-loop *contract* and must be carried **verbatim** into every repo derived from this template, alongside the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" that implements it. Without both in-repo, an agent working in the derived repo has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. + The repo runs a review loop on every PR: local agent iteration plus remote automated review (GitHub Copilot is the configured reviewer). Treat this as a contract regardless of which local agent authored the changes. +### Merge Gate (read this first) + +**Do not merge - and do not enable auto-merge - unless ALL of these hold:** + +1. Required status checks are green (`mergeStateStatus: CLEAN`), **and** +2. A Copilot review is confirmed on the **current head SHA** (not an earlier push), **and** +3. **Every** Copilot finding on that head SHA is closed out - all review threads resolved, **and** any issue-level Copilot comments (which have no resolve action) triaged and replied to - so zero outstanding findings remain, **and** +4. The maintainer has given **explicit** permission to merge. + +`mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). + +**Merging is not releasing.** A merge to a release branch does **not** by itself publish; publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. + ### Expected Review Loop 1. Push changes to the PR branch. @@ -115,7 +133,7 @@ The repo runs a review loop on every PR: local agent iteration plus remote autom 6. Reply to each thread and resolve what was addressed. 7. Re-run the loop after every fix push until no actionable findings remain. -`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Drive the loop to green - review confirmed on the latest head SHA and every actionable finding closed - and then **wait for the maintainer's explicit permission to merge**. The agent does not merge on its own (consistent with "default to staging"; merging is maintainer-authorized). +Drive the loop to green - review confirmed on the latest head SHA and every actionable finding closed - then stop and apply the **Merge Gate** above: all four preconditions must hold, and `mergeStateStatus: CLEAN` alone never satisfies it. For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract; that file owns the mechanics. @@ -150,11 +168,22 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul This repo is derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) and re-syncs against it periodically, not just at creation. - **Verbatim carries.** Pull the current template version of each shared artifact and re-apply it, adapting only this repo's placeholders: [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) (the Copilot review runbook - change only the ``/``/`` values in its API snippets), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`.editorconfig`](./.editorconfig), [`.gitattributes`](./.gitattributes), and this file's [PR Review Etiquette](#pr-review-etiquette) section. The `.editorconfig` EOL/per-extension block is always-verbatim; its `[*.cs]`/ReSharper block is .NET-only and is carried here. Keep `copilot-instructions.md` **narrow** (provider mechanics plus the commit/PR-title summary); project-specific conventions live in this file and the architecture deep-dive lives in [`ARCHITECTURE.md`](./ARCHITECTURE.md), not there - non-Copilot agents are not directed to that file. -- **CODESTYLE.md.** Re-sync the whole file from the template, then keep the **General** section plus the **.NET** language section and drop the language sections this repo doesn't ship (this repo is .NET-only). Repo-root placement is load-bearing - `AGENTS.md` and `.github/copilot-instructions.md` link it by relative path. Adapt the in-section repo-specific bits: the .NET project-folder list, the `InternalsVisibleTo` project names, and the VS Code task labels. Replacing the file wholesale and dropping whole sections is simpler to keep current than hand-editing per-language snippets. -- **.vscode/tasks.json.** Carry the named **clean-compile** task definitions verbatim - `.Net Build`, `CSharpier Format`, and `.Net Format` (which chains the first two then `dotnet format style --verify-no-changes`). Their names are owned by the `CODESTYLE.md` ".NET" section and their command sequence + arguments are the canonical clean-compile spec; don't loosen them. Convenience tasks are the adapt zone. +- **CODESTYLE.md.** Re-sync the whole file from the template; it is carried whole, so the language sections this repo doesn't ship stay inert (this repo is .NET-only). Repo-root placement is load-bearing - `AGENTS.md` and `.github/copilot-instructions.md` link it by relative path. The file is genericized with neutral placeholders, so re-sync is a clean wholesale overwrite. +- **.vscode/tasks.json.** Carry the named **clean-compile** task definitions verbatim - `.NET Build`, `CSharpier Format`, and `.NET Format` (which chains the first two then `dotnet format style --verify-no-changes`). Their names are owned by the `CODESTYLE.md` ".NET" section and their command sequence + arguments are the canonical clean-compile spec; don't loosen them. Convenience tasks are the adapt zone. - **Release notes.** Keep a short release-notes summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. - **Report drift upstream.** When a re-sync surfaces a template gap, an outdated instruction, or something that bit this repo and would bite the next derived repo, open an issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only patching locally - the template is the single source of truth, and this upstream-issue rule is this repo's only cross-repo obligation. Do not maintain or reference a "known downstream" registry, and do not name sibling repositories in docs, comments, or workflows - that registry and the maintainer fan-out duty live in the template hub only. +### Template adaptations + +Intentional deviations from a literal verbatim carry, kept on purpose: + +- **`.editorconfig`** carries the template verbatim plus a repo-wide block of CA-rule relaxations in `[*.cs]` (console-app, not a library; documented inline). The template's per-extension EOL block - including the `Dockerfile`/`*.Dockerfile` LF pins - is carried as-is. +- **`.gitattributes`** carries the template verbatim plus a `.husky/pre-commit text eol=lf` pin (this repo ships an extensionless Husky.Net hook, the exact case the template's `*.sh`/extensionless-script note calls out) and a `Docker/README.m4 text eol=lf` pin (m4 source rendered on Linux). +- **`.github/copilot-instructions.md`** keeps this repo's filled `ptr727`/`PlexCleaner` placeholders, its [`ARCHITECTURE.md`](./ARCHITECTURE.md) pointer, and the `.NET`-only language wording (no Python) - already adapted from the template's placeholder/multi-language form. +- **`CODESTYLE.md`** is carried whole from the template (genericized - generic project-name placeholders, both language sections); this repo's real project names live in `.csproj`/`.editorconfig`, not the style guide. +- **Husky.Net pre-commit hooks.** This repo runs Husky.Net (a `.husky/` hook runner + a `Husky.Net Run` VS Code task), inverting the template's no-hooks-by-default stance. +- **`.vscode/tasks.json`** carries the template's `.NET` clean-compile tasks verbatim (labels and command sequences) and adds this repo's Docker/Husky convenience tasks (the intentional deviation); task-label casing follows the template's official `.NET` casing per [`CODESTYLE.md`](./CODESTYLE.md). + ## Workflow YAML Conventions These conventions describe the target state. New and modified workflows must respect them; the rest of the repo is expected to be brought up to the same standard. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 083170ea..4280a9f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Critical performance feature - DO NOT break compatibility: - Each `.mkv` gets a `.PlexCleaner` sidecar JSON file - Contains: processing state, tool versions, media properties, file hash - Hash: First 64KB + last 64KB of file (not timestamp-based) -- Schema versioned (`SchemaVersion: 5` in `SidecarFileJsonSchema5`, global alias in `GlobalUsing.cs`) +- Schema versioned (`SchemaVersion: 5` in `SidecarFileJsonSchema5`, global alias in `GlobalUsings.cs`) - Processing skips verified files unless sidecar invalidated - State flags are bitwise: `StatesType` enum with `[Flags]` attribute - Sidecar operations: `Create()`, `Read()`, `Update()`, `Delete()` @@ -210,7 +210,7 @@ For formatter, EditorConfig, pre-commit hooks, line endings, and charset details - Prefer `Debug.Assert()` for internal invariants - Logging: Serilog with thread IDs (`Log.Information/Warning/Error`) - Exception handling: uses broad `catch(Exception)` blocks at boundary points -- Global usings: `GlobalUsing.cs` defines project-wide type aliases (`ConfigFileJsonSchema`, `SidecarFileJsonSchema`) +- Global usings: `GlobalUsings.cs` defines project-wide type aliases (`ConfigFileJsonSchema`, `SidecarFileJsonSchema`) - `Directory.Build.props`: Common MSBuild properties (`TargetFramework`, `Nullable`, `ImplicitUsings`, `AnalysisLevel`, etc.) shared across all projects live here at the solution root. Do not duplicate these in individual `.csproj` files -- only add a property to a `.csproj` when it is project-specific or overrides the shared default. - `Directory.Packages.props`: All NuGet package versions are centralised here via `PackageVersion` items. `PackageReference` elements in `.csproj` files must not include a `Version` attribute. Asset metadata (`PrivateAssets`, `IncludeAssets`) stays in the `.csproj` `PackageReference` element. @@ -311,7 +311,7 @@ Check states with `HasFlag()`, combine with `|=` ### Test Execution -- Task: `.Net Build` (VS Code task) for builds +- Task: `.NET Build` (VS Code task) for builds - Unit tests: `dotnet test` or VS Code test explorer - Docker tests: Download Matroska test files from GitHub - CI: Separate workflows for build tests and Docker tests @@ -345,7 +345,7 @@ Two-phase model - reusable `*-task.yml` workflows orchestrated by two entry poin - **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`, `publish-docker-readme-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`. +- Reusable tasks: `build-release-task.yml`, `build-executable-task.yml`, `build-docker-task.yml`, `build-toolversions-task.yml`, `publish-docker-readme-task.yml`, `build-datebadge-task.yml`, `get-version-task.yml`. Most thread a required `branch` input (config keys off it, never `github.ref_name`) plus `ref`/`smoke`. Exception: `build-datebadge-task.yml` takes no `branch` input - it's caller-gated (the publisher invokes it only when `main` is published), since the badge tracks the last `main` build and has no per-branch context. - 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`). @@ -382,7 +382,7 @@ Two-phase model - reusable `*-task.yml` workflows orchestrated by two entry poin - Handle cross-platform paths (`Path.Combine`, forward slashes in Docker) - Use modern C# features (collection expressions, pattern matching, extensions) - Version schemas when making breaking changes -- Update global using aliases in `GlobalUsing.cs` when changing schema versions +- Update global using aliases in `GlobalUsings.cs` when changing schema versions ### Performance Considerations @@ -438,7 +438,7 @@ Two-phase model - reusable `*-task.yml` workflows orchestrated by two entry poin - **SelectMediaProps.cs**: Track filtering and selection logic - **CommandLineOptions.cs**: CLI parsing, option definitions - **Extensions.cs**: Logger extensions, implicit class extensions -- **GlobalUsing.cs**: Global type aliases for schema versions +- **GlobalUsings.cs**: Global type aliases for schema versions - **KeepAwake.cs**: System sleep prevention - **PlexCleaner.defaults.json**: Canonical configuration reference - **.editorconfig** / **.csharpier.json**: Code style definitions diff --git a/CODESTYLE.md b/CODESTYLE.md index 7385c8e1..7dcad425 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,18 +1,16 @@ # Code Style and Formatting Rules -This is the single code-style guide for the repo. The **General** section applies to every language and is always carried. Each **language section** (.NET) is self-contained and **droppable**: a repo with no .NET side drops the .NET section - the same per-language model as [`.editorconfig`](./.editorconfig), whose `[*.cs]` block a non-.NET repo drops. +This is the single code-style guide for the repo. The **General** section applies to every language. Each **language section** (.NET, Python) is self-contained: a repo reads only the section(s) for the languages it ships and ignores the rest. The whole file is carried, not trimmed - an unused-language section costs nothing and keeps re-sync a clean overwrite, the same carry-whole model as [`.editorconfig`](./.editorconfig), whose inert `[*.cs]` block a non-.NET repo keeps. Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. -This file lives at the repo root. [AGENTS.md](./AGENTS.md) links it as `./CODESTYLE.md`; [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) links it as `../CODESTYLE.md`. - ## General These rules apply to every language in the repo. ### Tooling Names and Casing -Use each tool's official casing in docs and prose - `.NET`, `CSharpier`, `Husky.Net`. Don't invent personal variants. (Note: this repo's VS Code task labels use `.Net` casing for historical reasons - the labels are referenced verbatim below, but prose should use `.NET`.) +Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `CSharpier`, `ruff`, `pyright`, `uv`. Don't invent personal variants. ### Clean-Compile Verification @@ -20,13 +18,14 @@ Each language defines a **clean-compile** verification - the combination of buil - **Run it after every code change.** The relevant language's clean-compile must pass before you commit; CI runs the same checks as a backstop. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly; either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). +- **A local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated - but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless; a local gate is an additive convenience a repo may wire and keep - Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions -- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. +- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings - fix them. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md](./AGENTS.md) and [README.md](./README.md), which has nothing to do with code analysis.) - **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: 1. An **in-code annotation on the specific symbol**, with a justification - the language's attribute/comment form, never a blanket pragma spanning a region. - 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig`). + 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. - **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. @@ -39,11 +38,9 @@ These apply repo-wide, in every directory: ## .NET -*This section applies only to the .NET side. A repo with no .NET projects drops the whole section - see [Adopting Without .NET](#adopting-without-net) at its end.* - -This is the style guide for the **.NET projects** in this repo. This repo ships [`PlexCleaner/`](./PlexCleaner/) (the CLI application, .NET 10, AOT) and [`PlexCleanerTests/`](./PlexCleanerTests/) (xUnit v3 + AwesomeAssertions). There is also a [`Docker/`](./Docker/) folder for the container build. +*This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* -Shared MSBuild properties and central package versions are centralized at the repo root: [`Directory.Build.props`](./Directory.Build.props) holds the shared MSBuild properties, and [`Directory.Packages.props`](./Directory.Packages.props) holds central package versions (so a `PackageReference` is declared without a `Version` attribute). +This is the style guide for any **.NET projects** in this repo. ### Build Requirements @@ -51,28 +48,25 @@ Shared MSBuild properties and central package versions are centralized at the re **CRITICAL**: All builds must complete without warnings. The project enforces this through: -1. **The `.Net Format` clean-compile task** (see [Clean-Compile Verification](#clean-compile-verification)) - - The .NET clean-compile is the **`.Net Format`** VS Code task, which chains `CSharpier Format` -> `.Net Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./.vscode/tasks.json). - - After any code change it must pass before commit. Run the `.Net Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.Net Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. +1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification](#clean-compile-verification)) + - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`](./.vscode/tasks.json). + - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`](./.vscode/tasks.json) exactly - `CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify - without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. 2. **Analyzer configuration** - - `latest-all` - - `true` - - Analyzer severity is `suggestion`, but all warnings must be addressed - see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions); do not relax rules to dodge them. + - `true` with `latest-all` and `All` (full analyzer set enabled) + - `true` - any diagnostic surfaced as a warning fails the build, so it must be fixed or deliberately suppressed, not left to accumulate (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)) -3. **Husky.Net pre-commit hook** - - The pre-commit hook (`dotnet husky run`) runs the CSharpier and style checks before a commit lands. CI runs the same checks as a backstop. +3. **CI lint backstop** + - CI runs the clean-compile checks on every PR as the authoritative backstop + - Git hooks are optional; a repo may wire a local runner (Husky.Net) for pre-commit enforcement, but CI is the gate that matters #### Build Tasks -Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The first three are the clean-compile set, carried verbatim; the rest are convenience tasks: +Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run Task** - or an agent's task-running tool). The three clean-compile tasks below are carried verbatim; a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: -- `.Net Build`: Build with diagnostic verbosity *(clean-compile)* +- `.NET Build`: Build with diagnostic verbosity *(clean-compile)* - `CSharpier Format`: Auto-format code with CSharpier *(clean-compile)* -- `.Net Format`: Run CSharpier and build, then verify formatting and style with `--verify-no-changes` *(clean-compile; the task to run after edits)* -- `.Net Tool Update`: Update dotnet tools *(convenience)* -- `.Net Outdated Upgrade`: Upgrade outdated NuGet dependencies, interactive prompt *(convenience)* -- `Husky.Net Run`: Run the pre-commit hooks manually *(convenience)* +- `.NET Format`: Run CSharpier and build, then verify formatting and style with `--verify-no-changes` *(clean-compile; the task to run after edits)* ### Tooling and Editor @@ -82,14 +76,12 @@ Available VS Code tasks (run them from VS Code's task runner - **Terminal -> Run - Invoked by the `CSharpier Format` task / `dotnet csharpier format --log-level=debug .` 2. **dotnet format**: Style verification - Verify no changes: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed` -3. **Husky.Net**: Git hooks for automated checks - - Installed as a local dotnet tool (via `dotnet tool restore`) - - Install Git hooks locally with `dotnet husky install` - - Pre-commit hooks run formatting and style checks (`dotnet husky run`) -4. **Other tools** +3. **Other tools** - `dotnet-outdated-tool`: Dependency update checks - Nerdbank.GitVersioning: Version management +CI is the authoritative lint backstop. Local pre-commit hooks are optional - wire Husky.Net (or another runner) if you want local enforcement. + #### Editor Baseline 1. **Required VS Code extensions**: CSharpier, markdownlint, CSpell @@ -104,7 +96,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to 1. **File-scoped namespaces** ```csharp - namespace PlexCleaner; + namespace Example.Project.Library; ``` 2. **Nullable reference types**: Enabled (`enable`) @@ -116,7 +108,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - Top-level statements for console apps - Pattern matching over traditional checks - Collection expressions when types loosely match - - Extension methods using `extension()` syntax + - Extension methods - the classic `this`-parameter form, or an `extension() { ... }` block on C# 14+ - Implicit object creation when type is apparent - Range and index operators @@ -158,7 +150,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to #### Code Structure -1. **Global usings**: Use `GlobalUsing.cs` for common namespaces +1. **Global usings**: Use `GlobalUsings.cs` for common namespaces ```csharp global using System; @@ -172,9 +164,9 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to ```csharp using System.CommandLine; using System.Runtime.CompilerServices; - using PlexCleaner; + using Example.Project.Library; - namespace PlexCleaner; + namespace Example.Project.Console; ``` 3. **Braces**: Allman style @@ -208,7 +200,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - `true` - Missing XML comments for public APIs are suppressed (`.editorconfig`) - Must document all public surfaces. - - Single-line summaries, additional details in remarks, document input parameters, returns values, exceptions, and add crefs + - Single-line summaries, additional details in remarks, document input parameters, return values, exceptions, and add crefs ```csharp /// @@ -336,11 +328,12 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- - Include SourceLink: `true` - Embed untracked sources: `true` -4. **Internal visibility**: `PlexCleaner.csproj` exposes its internals to the `PlexCleanerTests` project only +4. **Internal visibility**: Use `InternalsVisibleTo` for test and benchmark access (adapt the project names to your repo's test/benchmark projects) ```xml - + + ``` @@ -348,6 +341,123 @@ Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions](#analyzer- 1. **Code reviews**: All changes go through pull requests -### Adopting Without .NET +## Python + +*This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* + +This is the style guide for any **Python project(s)** in this repo. + +### Toolchain + +| Tool | Role | Config | +|---|---|---| +| [uv](https://docs.astral.sh/uv/) | env, deps, build, publish | `pyproject.toml` `[dependency-groups]`, `uv.lock` | +| [hatchling](https://hatch.pypa.io/latest/) | build backend | `pyproject.toml` `[build-system]` | +| [ruff](https://docs.astral.sh/ruff/) | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright](https://microsoft.github.io/pyright/) | type checker | `pyproject.toml` `[tool.pyright]` | +| [pytest](https://docs.pytest.org/) | test runner | `pyproject.toml` `[tool.pytest.ini_options]` | + +`pyright` is consumed in two places: as a dev dependency (`uv run pyright` for CI/scripted runs) and via VS Code's **Pylance** extension (which embeds pyright). The standalone `ms-pyright.pyright` extension is in `unwantedRecommendations` because Pylance covers it. `mypy` is **not used** here - don't introduce it. + +### Local Development Loop + +From inside the Python project directory: + +```sh +uv sync # creates .venv, installs deps + dev group +uv run ruff format # auto-format +uv run ruff check --fix # auto-fix lint +uv run ruff check # verify lint clean +uv run ruff format --check # verify format clean +uv run pyright # verify types +uv run pytest # run tests +uv build # produce wheel + sdist in ./dist +``` + +The Python clean-compile (see [Clean-Compile Verification](#clean-compile-verification)) is `uv run ruff format` + `uv run ruff check` + `uv run pyright`; run it (plus `uv run pytest`) before committing. These are documented commands, not VS Code tasks. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and `pyright` yourself if you want local enforcement. + +### Layout + +`src` layout - keeps the package out of the repo root and prevents accidental imports of unbuilt code: + +```text +/ + pyproject.toml + README.md + uv.lock # committed for reproducible CI + src/ + / + __init__.py + _version.py + .py + tests/ + __init__.py + test_.py +``` + +### Code Style + +#### Formatting and Linting + +- **`ruff format` is authoritative.** Don't argue with the formatter; if it reformats your code, that's the final form. Configure (line length, target version) in `pyproject.toml` `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes; let the tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add new rule families project-wide rather than scattering inline `# noqa` markers. +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint - fix it (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). + +#### Comments + +- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine when you need to document a non-obvious implementation constraint, a local trade-off, or coupling that future edits could easily break. Keep that rationale next to the affected block so the reviewer/maintainer sees it at edit-time. +- **Don't explain *what* the code does** - well-named identifiers handle that. Don't reference the current task ("added for X", "used by Y"); that belongs in the PR description. + +#### Docstrings + +- Follow [PEP 257](https://peps.python.org/pep-0257/). Focus docstrings primarily on the **behavior contract** (what callers and tests can rely on), public semantics, and edge-case expectations. Implementation-local rationale belongs in inline `#` comments, not docstrings. +- A short one-liner is fine for trivial functions and tests with self-documenting names. +- For non-trivial behavior - non-obvious test scenarios, contracts a test pins, edge cases callers must know about, design trade-offs that are load-bearing for future maintainers - write a one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when the contract earns it. +- Design notes belong **in the code** (docstrings or inline comments). They do NOT belong in [`HISTORY.md`](./HISTORY.md) - that file is end-user release notes, not a design log. + +#### Type Hints + +- **All public APIs are typed.** Pyright runs on `src/` in strict mode (`[tool.pyright]` `strict = ["src"]`); tests run in standard mode. +- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward references. +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the constraint. If a recurring false positive needs suppression, configure it project-wide in `[tool.pyright]`. A new port doesn't change this - fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions](#analyzer-diagnostics-and-suppressions)). + +#### Naming + +- `snake_case` for functions, methods, variables, modules, package directories. +- `PascalCase` for classes, type aliases, type vars, enum members. +- `UPPER_SNAKE_CASE` for module-level constants. +- Single leading underscore for module-private; double leading underscore for name-mangled (rare - usually means rethink the design). + +#### Imports + +- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family (isort-equivalent). Don't hand-sort. +- Standard library first, then third-party, then first-party (the project itself), each block separated by a blank line - ruff enforces this automatically. +- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. + +#### Patterns to Avoid + +- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars** - just delete. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code; only validate at boundaries (user input, parsed config, external APIs). +- **Don't use exceptions for expected control flow.** Exceptions are for *unexpected* states. +- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific exception and document why it's safe, or let it propagate. + +### Tests + +- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. +- One test file per module under test, named `test_.py`. +- Test functions named `test__` - descriptive, not numbered. +- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead of setup/teardown methods. +- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on are usually clearer and break less than `unittest.mock` magic. +- **Test edge cases that the docstring promises**, not implementation details. If the test breaks when you refactor *without changing behavior*, the test is asserting on an implementation detail. + +### Versioning + +`_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a version.json bridge, or manual bumps), no new PyPI versions will land - publishing with `skip-existing: true` keeps a stuck placeholder version from failing the run. + +### Linter Cleanliness + +Before pushing or opening a PR: -If a derived project has no .NET side, drop this entire `.NET` section and delete the .NET projects and their build/release wiring: the `[*.cs]` / ReSharper block in `.editorconfig`, the `.Net` task group in `.vscode/tasks.json`, and the `nuget` entries in `.github/dependabot.yml`. +- VS Code's **Problems** pane should be quiet for the files you touched. The relevant linters are ruff (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's bundled Pylance). +- The CI gate is `uv run ruff check && uv run ruff format --check && uv run pyright && uv run pytest` - same as the local commands above, run from the Python project directory. +- Markdown in this directory follows the repo-wide [Markdown and Spelling](#markdown-and-spelling) rules. diff --git a/Directory.Packages.props b/Directory.Packages.props index 765afe0f..ce38ae27 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,11 @@ - - + + - + diff --git a/Docker/Dockerfile b/Docker/Dockerfile index 0a36c7bb..ba10c040 100644 --- a/Docker/Dockerfile +++ b/Docker/Dockerfile @@ -1,144 +1,144 @@ -# Description: Ubuntu latest release -# Based on: ubuntu:rolling -# .NET install: Ubuntu repository -# Platforms: linux/amd64, linux/arm64 -# Tag: ptr727/plexcleaner:ubuntu - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing ubuntu:rolling /bin/bash -# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:ubuntu /bin/bash -# export DEBIAN_FRONTEND=noninteractive - -# Build Dockerfile -# docker buildx create --name "plexcleaner" --use -# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Dockerfile . - -# Build and log output -# docker buildx build --no-cache --progress=plain --platform linux/amd64 --file ./Docker/Dockerfile . 2>&1 | tee build.log - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag plexcleaner:ubuntu --file ./Docker/Dockerfile . -# docker run -it --rm --name PlexCleaner-Test plexcleaner:ubuntu /bin/bash - - -# Builder layer -FROM --platform=$BUILDPLATFORM ubuntu:rolling AS builder - -# Layer workdir -WORKDIR /Builder - -# Build platform args -ARG TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM - -# PlexCleaner build attribute configuration -ARG BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install .NET SDK -# https://documentation.ubuntu.com/ubuntu-for-developers/howto/dotnet-setup -# https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install -RUN apt install -y --install-suggests dotnet-sdk-10.0 - -# Copy source -COPY ./Samples/. ./Samples/. -COPY ./PlexCleanerTests/. ./PlexCleanerTests/. -COPY ./PlexCleaner/. ./PlexCleaner/. -COPY ./.editorconfig ./ -COPY ./Directory.Build.props ./ -COPY ./Directory.Packages.props ./ - -# Build project -COPY --chmod=ug=rwx,o=rx ./Docker/Build.sh ./ -RUN ./Build.sh - - -# Final layer -FROM ubuntu:rolling AS final - -# Image label -ARG LABEL_VERSION="1.0.0.0" -LABEL name="PlexCleaner" \ - version=${LABEL_VERSION} \ - description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ - maintainer="Pieter Viljoen " - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install dependencies -RUN apt install -y --no-install-recommends \ - ca-certificates \ - locales \ - locales-all \ - p7zip-full \ - tzdata \ - wget \ - && locale-gen --no-purge en_US en_US.UTF-8 - -# Set locale to UTF-8 after running locale-gen -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -ENV TZ=Etc/UTC \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 - -# Install .NET Runtime -RUN apt install -y --install-suggests dotnet-runtime-10.0 - -# Install media tools -# https://packages.ubuntu.com/questing/ffmpeg -# https://packages.ubuntu.com/questing/handbrake-cli -# https://packages.ubuntu.com/questing/mediainfo -# https://packages.ubuntu.com/questing/mkvtoolnix -RUN apt install -y --no-install-recommends \ - ffmpeg \ - handbrake-cli \ - mediainfo \ - mkvtoolnix - -# Cleanup -RUN apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy PlexCleaner from builder layer -COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner - -# Copy test script -COPY --chmod=ug=rwx,o=rx ./Docker/Test.sh /Test/ - -# Install debug tools -COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./ -RUN ./InstallDebugTools.sh \ - && rm -rf ./InstallDebugTools.sh - -# Copy version script -COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh /PlexCleaner/ - -# Print version information -ARG TARGETPLATFORM \ - BUILDPLATFORM -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /PlexCleaner/Version.sh; \ - fi +# Description: Ubuntu latest release +# Based on: ubuntu:rolling +# .NET install: Ubuntu repository +# Platforms: linux/amd64, linux/arm64 +# Tag: ptr727/plexcleaner:ubuntu + +# Docker build debugging: +# --progress=plain +# --no-cache + +# Test image in shell: +# docker run -it --rm --pull always --name Testing ubuntu:rolling /bin/bash +# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:ubuntu /bin/bash +# export DEBIAN_FRONTEND=noninteractive + +# Build Dockerfile +# docker buildx create --name "plexcleaner" --use +# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Dockerfile . + +# Build and log output +# docker buildx build --no-cache --progress=plain --platform linux/amd64 --file ./Docker/Dockerfile . 2>&1 | tee build.log + +# Test linux/amd64 target +# docker buildx build --load --platform linux/amd64 --tag plexcleaner:ubuntu --file ./Docker/Dockerfile . +# docker run -it --rm --name PlexCleaner-Test plexcleaner:ubuntu /bin/bash + + +# Builder layer +FROM --platform=$BUILDPLATFORM ubuntu:rolling AS builder + +# Layer workdir +WORKDIR /Builder + +# Build platform args +ARG TARGETPLATFORM \ + TARGETARCH \ + BUILDPLATFORM + +# PlexCleaner build attribute configuration +ARG BUILD_CONFIGURATION="Debug" \ + BUILD_VERSION="1.0.0.0" \ + BUILD_FILE_VERSION="1.0.0.0" \ + BUILD_ASSEMBLY_VERSION="1.0.0.0" \ + BUILD_INFORMATION_VERSION="1.0.0.0" \ + BUILD_PACKAGE_VERSION="1.0.0.0" + +# Prevent EULA and confirmation prompts in installers +ENV DEBIAN_FRONTEND=noninteractive + +# Upgrade +RUN apt update \ + && apt upgrade -y + +# Install .NET SDK +# https://documentation.ubuntu.com/ubuntu-for-developers/howto/dotnet-setup +# https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install +RUN apt install -y --install-suggests dotnet-sdk-10.0 + +# Copy source +COPY ./Samples/. ./Samples/. +COPY ./PlexCleanerTests/. ./PlexCleanerTests/. +COPY ./PlexCleaner/. ./PlexCleaner/. +COPY ./.editorconfig ./ +COPY ./Directory.Build.props ./ +COPY ./Directory.Packages.props ./ + +# Build project +COPY --chmod=ug=rwx,o=rx ./Docker/Build.sh ./ +RUN ./Build.sh + + +# Final layer +FROM ubuntu:rolling AS final + +# Image label +ARG LABEL_VERSION="1.0.0.0" +LABEL name="PlexCleaner" \ + version=${LABEL_VERSION} \ + description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ + maintainer="Pieter Viljoen " + +# Prevent EULA and confirmation prompts in installers +ENV DEBIAN_FRONTEND=noninteractive + +# Upgrade +RUN apt update \ + && apt upgrade -y + +# Install dependencies +RUN apt install -y --no-install-recommends \ + ca-certificates \ + locales \ + locales-all \ + p7zip-full \ + tzdata \ + wget \ + && locale-gen --no-purge en_US en_US.UTF-8 + +# Set locale to UTF-8 after running locale-gen +# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md +ENV TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 + +# Install .NET Runtime +RUN apt install -y --install-suggests dotnet-runtime-10.0 + +# Install media tools +# https://packages.ubuntu.com/questing/ffmpeg +# https://packages.ubuntu.com/questing/handbrake-cli +# https://packages.ubuntu.com/questing/mediainfo +# https://packages.ubuntu.com/questing/mkvtoolnix +RUN apt install -y --no-install-recommends \ + ffmpeg \ + handbrake-cli \ + mediainfo \ + mkvtoolnix + +# Cleanup +RUN apt autoremove -y \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* + +# Copy PlexCleaner from builder layer +COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner + +# Copy test script +COPY --chmod=ug=rwx,o=rx ./Docker/Test.sh /Test/ + +# Install debug tools +COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./ +RUN ./InstallDebugTools.sh \ + && rm -rf ./InstallDebugTools.sh + +# Copy version script +COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh /PlexCleaner/ + +# Print version information +ARG TARGETPLATFORM \ + BUILDPLATFORM +RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ + /PlexCleaner/Version.sh; \ + fi diff --git a/Docker/README.m4 b/Docker/README.m4 index 4956ea1f..6e8c0150 100644 --- a/Docker/README.m4 +++ b/Docker/README.m4 @@ -1,35 +1,35 @@ -changequote(`{{', `}}') -# PlexCleaner - -Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - -## License - -Licensed under the [MIT License](https://github.com/ptr727/PlexCleaner/LICENSE) -![GitHub License](https://img.shields.io/github/license/ptr727/PlexCleaner) - -## Project - -Code and Pipeline is on [GitHub](https://github.com/ptr727/PlexCleaner).\ -Binary releases are published on [GitHub Releases](https://github.com/ptr727/PlexCleaner/releases).\ -Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/plexcleaner).\ -Images are updated weekly with the latest upstream updates. - -## Usage - -Refer to the [project](https://github.com/ptr727/PlexCleaner) page. - -## Docker Tags - -- `latest`: - - Based on [Ubuntu Rolling](https://releases.ubuntu.com/) `ubuntu:rolling` latest stable release base image. - - Multi-architecture image supporting `linux/amd64` and `linux/arm64` builds. - - Builds from the release [main branch](https://github.com/ptr727/PlexCleaner/tree/main). -- `develop`: - - Builds from the pre-release [develop branch](https://github.com/ptr727/PlexCleaner/tree/develop). - -## Image Information - -```text -include({{latest.ver}}) -``` +changequote(`{{', `}}') +# PlexCleaner + +Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. + +## License + +Licensed under the [MIT License](https://github.com/ptr727/PlexCleaner/LICENSE) +![GitHub License](https://img.shields.io/github/license/ptr727/PlexCleaner) + +## Project + +Code and Pipeline is on [GitHub](https://github.com/ptr727/PlexCleaner).\ +Binary releases are published on [GitHub Releases](https://github.com/ptr727/PlexCleaner/releases).\ +Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/plexcleaner).\ +Images are updated weekly with the latest upstream updates. + +## Usage + +Refer to the [project](https://github.com/ptr727/PlexCleaner) page. + +## Docker Tags + +- `latest`: + - Based on the [Ubuntu Rolling](https://releases.ubuntu.com/) `ubuntu:rolling` base image (latest interim release). + - Multi-architecture image supporting `linux/amd64` and `linux/arm64` builds. + - Builds from the release [main branch](https://github.com/ptr727/PlexCleaner/tree/main). +- `develop`: + - Builds from the pre-release [develop branch](https://github.com/ptr727/PlexCleaner/tree/develop). + +## Image Information + +```text +include({{latest.ver}}) +``` diff --git a/PlexCleaner.code-workspace b/PlexCleaner.code-workspace index f75535c6..4a82e542 100644 --- a/PlexCleaner.code-workspace +++ b/PlexCleaner.code-workspace @@ -215,6 +215,7 @@ "truehd", "typeorder", "tzdata", + "unmappable", "unref", "updatereadme", "updatesidecar", diff --git a/PlexCleaner/ConfigFileJsonSchema.cs b/PlexCleaner/ConfigFileJsonSchema.cs index 54d50d08..09f3f7b1 100644 --- a/PlexCleaner/ConfigFileJsonSchema.cs +++ b/PlexCleaner/ConfigFileJsonSchema.cs @@ -5,7 +5,7 @@ // Mark changed or removed attributes as [Obsolete] and [Json.Schema.Generation.JsonExclude] and remove [JsonRequired] // Add new attributes to the new class and mark as [JsonRequired] // Update the Upgrade() method to handle upgrading from the previous version -// Update GlobalUsing.cs global using statements to the latest version +// Update GlobalUsings.cs global using statements to the latest version using System.Text.Json; using System.Text.Json.Nodes; diff --git a/PlexCleaner/GlobalUsing.cs b/PlexCleaner/GlobalUsings.cs similarity index 100% rename from PlexCleaner/GlobalUsing.cs rename to PlexCleaner/GlobalUsings.cs