diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3093515..b6863ac 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,18 @@ PlexCleaner is a .NET 10.0 CLI utility that optimizes media files for Direct Pla The tool orchestrates external media processing tools (FFmpeg, HandBrake, MkvToolNix, MediaInfo, 7-Zip) via CLI wrappers. +## Branching, Releases, and Bot Behavior + +For full rationale see [`AGENTS.md`](../AGENTS.md). Quick rules: + +- `feature → develop → main`. PRs only. +- Develop accepts **squash merges only**; main accepts **merge commits only**. Don't suggest rebase-merge — it's disabled at the repo level. +- Both branches **auto-publish on push**: develop produces NBGV prereleases (`X.Y.Z-g{sha}`) tagged `develop` on Docker Hub; main produces stable releases (`X.Y.Z`) tagged `latest`. +- Dependabot targets **both** `main` and `develop` with the same ecosystems; major NuGet bumps gate on human review, everything else auto-merges via App-token-driven merge-bot. +- Every third-party GitHub Action is pinned to a full commit SHA with a `# vX.Y.Z` comment. Don't introduce `@v6` / `@main` / `@master` floating refs. +- Don't recommend `git push --force` or `--force-with-lease`; both rulesets enforce `non_fast_forward`. +- `version.json`'s `publicReleaseRefSpec` is `^refs/heads/main$` — bumping the base `version` field is the only manual versioning action. + ## Documentation User-facing documentation is organized as follows: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5d47fa7..152efec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,21 +3,41 @@ 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: - - "*" + - 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 fc3355a..e6cb9df 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -17,7 +17,7 @@ jobs: - name: Build BYOB date badge step if: ${{ github.ref_name == 'main' }} - uses: RubbaBoy/BYOB@v1 + uses: RubbaBoy/BYOB@24f464284c1fd32028524b59607d417a2e36fee7 # v1.3.0 with: name: lastbuild label: "Last Build" diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index cc9e7b6..922af0f 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -32,28 +32,28 @@ jobs: steps: - name: Checkout step - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup QEMU step - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 with: platforms: linux/amd64,linux/arm64 - name: Setup Buildx step - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 with: platforms: linux/amd64,linux/arm64 # Always login to Docker Hub, not just on push, to benefit from # higher rate limits with a Docker subscription for pulls and cache - name: Login to Docker Hub step - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Docker build and push step - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: ${{ inputs.push }} diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index 0667913..d85fd93 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -25,12 +25,12 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build executable project step run: | @@ -46,7 +46,7 @@ jobs: -property:PackageVersion=${{ needs.get-version.outputs.SemVer2 }} - name: Upload matrix build artifacts step - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: publish-${{ matrix.runtime }} path: ${{ runner.temp }}/publish @@ -61,7 +61,7 @@ jobs: steps: - name: Download matrix build artifacts step - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: publish-* merge-multiple: true @@ -72,7 +72,7 @@ jobs: - name: Upload build artifacts step id: artifact-upload-step - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: executable-build path: ${{ runner.temp }}/PlexCleaner.7z diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 6ae18c0..e49a381 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -43,20 +43,22 @@ jobs: steps: - name: Checkout code step - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download executable build artifacts step - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} path: ./Publish - name: Create GitHub release step - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: - generate_release_notes: true tag_name: ${{ needs.get-version.outputs.SemVer2 }} + target_commitish: ${{ github.sha }} prerelease: ${{ github.ref_name != 'main' }} + generate_release_notes: true + fail_on_unmatched_files: true files: | LICENSE README.md diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 2c52cb2..c8b522c 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -27,15 +27,15 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Run Nerdbank.GitVersioning tool step id: nbgv - uses: dotnet/nbgv@master + uses: dotnet/nbgv@3cf2d96c2aa00675081b59f401356ac1fb81092f # v0.5.1 diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index a5656d2..d2f3f73 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,6 +1,6 @@ name: Merge bot pull request action -on: +"on": pull_request: types: [opened, reopened, synchronize] @@ -13,9 +13,12 @@ jobs: merge-dependabot: name: Merge dependabot pull request job runs-on: ubuntu-latest - # To prevent abuse, the PR must come from Dependabot and the PR must originate from this repository. + # 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.actor == 'dependabot[bot]' && + github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository permissions: contents: write @@ -23,19 +26,49 @@ jobs: 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 and publish-periodic-docker-release.yml on the + # merge commit and prevent develop's auto-prerelease/Docker rebuild. + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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@v3 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - # Merge any non-NuGet update, e.g. GitHub Actions often updates v1 to v2. - # Merge NuGet only for non-major updates, e.g. major updates may build but break functionality. + # 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: gh pr merge --auto --squash "$PR_URL" + 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: ${{secrets.GITHUB_TOKEN}} + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish-periodic-docker-release.yml b/.github/workflows/publish-periodic-docker-release.yml index f09ca5f..5651069 100644 --- a/.github/workflows/publish-periodic-docker-release.yml +++ b/.github/workflows/publish-periodic-docker-release.yml @@ -34,35 +34,35 @@ jobs: steps: - - name: Get image size step - run: | - mkdir -p ${{ runner.temp }}/versions - touch ${{ runner.temp }}/versions/${{ matrix.file }} - echo Image: docker.io/ptr727/plexcleaner:${{ matrix.tag }} >> ${{ runner.temp }}/versions/${{ matrix.file }} - echo Size: $(docker manifest inspect -v docker.io/ptr727/plexcleaner:${{ matrix.tag }} | jq '.[] | select(.Descriptor.platform.architecture=="amd64") | [.OCIManifest.layers[].size] | add' | numfmt --to=iec) >> ${{ runner.temp }}/versions/${{ matrix.file }} - - - name: Write tool versions to file step - uses: addnab/docker-run-action@v3 - with: - image: docker.io/ptr727/plexcleaner:${{ matrix.tag }} - options: --volume ${{ runner.temp }}/versions:/versions + - name: Get image size step run: | - echo OS: $(. /etc/os-release; echo $PRETTY_NAME) >> /versions/${{ matrix.file }} - echo dotNET: $(dotnet --info) >> /versions/${{ matrix.file }} - echo PlexCleaner: $(/PlexCleaner/PlexCleaner --version) >> /versions/${{ matrix.file }} - echo HandBrakeCLI: $(HandBrakeCLI --version) >> /versions/${{ matrix.file }} - echo MediaInfo: $(mediainfo --version) >> /versions/${{ matrix.file }} - echo MkvMerge: $(mkvmerge --version) >> /versions/${{ matrix.file }} - echo FfMpeg: $(ffmpeg -version) >> /versions/${{ matrix.file }} - - - name: Print versions step - run: cat ${{ runner.temp }}/versions/${{ matrix.file }} - - - name: Upload version artifacts step - uses: actions/upload-artifact@v7 - with: - name: versions-${{ matrix.file }} - path: ${{ runner.temp }}/versions/${{ matrix.file }} + mkdir -p ${{ runner.temp }}/versions + touch ${{ runner.temp }}/versions/${{ matrix.file }} + echo Image: docker.io/ptr727/plexcleaner:${{ matrix.tag }} >> ${{ runner.temp }}/versions/${{ matrix.file }} + echo Size: $(docker manifest inspect -v docker.io/ptr727/plexcleaner:${{ matrix.tag }} | jq '.[] | select(.Descriptor.platform.architecture=="amd64") | [.OCIManifest.layers[].size] | add' | numfmt --to=iec) >> ${{ runner.temp }}/versions/${{ matrix.file }} + + - name: Write tool versions to file step + uses: addnab/docker-run-action@4f65fabd2431ebc8d299f8e5a018d79a769ae185 # v3 + with: + image: docker.io/ptr727/plexcleaner:${{ matrix.tag }} + options: --volume ${{ runner.temp }}/versions:/versions + run: | + echo OS: $(. /etc/os-release; echo $PRETTY_NAME) >> /versions/${{ matrix.file }} + echo dotNET: $(dotnet --info) >> /versions/${{ matrix.file }} + echo PlexCleaner: $(/PlexCleaner/PlexCleaner --version) >> /versions/${{ matrix.file }} + echo HandBrakeCLI: $(HandBrakeCLI --version) >> /versions/${{ matrix.file }} + echo MediaInfo: $(mediainfo --version) >> /versions/${{ matrix.file }} + echo MkvMerge: $(mkvmerge --version) >> /versions/${{ matrix.file }} + echo FfMpeg: $(ffmpeg -version) >> /versions/${{ matrix.file }} + + - name: Print versions step + run: cat ${{ runner.temp }}/versions/${{ matrix.file }} + + - name: Upload version artifacts step + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: versions-${{ matrix.file }} + path: ${{ runner.temp }}/versions/${{ matrix.file }} update-readme: name: Create Docker README.md job @@ -72,27 +72,27 @@ jobs: steps: - - name: Checkout step - uses: actions/checkout@v6 - - - name: Download version artifacts step - uses: actions/download-artifact@v8 - with: - pattern: versions-* - merge-multiple: true - path: ${{ runner.temp }}/versions - - - name: Create README.md from README.m4 step - run: m4 --include=${{ runner.temp }}/versions ./Docker/README.m4 > ${{ runner.temp }}/README.md - - - name: Update Docker Hub README.md step - uses: peter-evans/dockerhub-description@v5 - 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: Checkout step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download version artifacts step + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: versions-* + merge-multiple: true + path: ${{ runner.temp }}/versions + + - name: Create README.md from README.m4 step + run: m4 --include=${{ runner.temp }}/versions ./Docker/README.m4 > ${{ runner.temp }}/README.md + + - name: Update Docker Hub README.md step + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + repository: ptr727/plexcleaner + short-description: ${{ github.event.repository.description }} + readme-filepath: ${{ runner.temp }}/README.md date-badge: name: Create BYOB date badge job diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml index 4340986..6b5cfae 100644 --- a/.github/workflows/test-release-task.yml +++ b/.github/workflows/test-release-task.yml @@ -13,12 +13,12 @@ jobs: steps: - name: Setup .NET SDK step - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: 10.x - name: Checkout code step - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check code style step run: | diff --git a/AGENTS.md b/AGENTS.md index 99126a5..815bd45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,60 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ - **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. - **Staging is the limit.** Prepare and stage file changes; the developer runs `git commit` in their own environment where signing keys are available. +## Branches and merging + +- Pipeline is `feature → develop → main`. Both branches are protected by branch rulesets; everything lands via PR. +- **Feature → develop PRs squash-merge** (single commit on develop, PR title becomes the commit message; never rebase-merge). +- **Develop → main PRs merge-commit** (one merge commit on main per release, develop's tip becomes a second parent and stays in main's ancestry — see [Develop → Main Promotion](#develop--main-promotion)). +- Open feature PRs against `develop`. `develop → main` is how stable releases are cut. + +Repo settings reflect this: `allow_merge_commit=true`, `allow_squash_merge=true`, `allow_rebase_merge=false`, `allow_auto_merge=true`. The `develop` ruleset enforces `allowed_merge_methods=["squash"]` and `required_linear_history`. The `main` ruleset enforces `allowed_merge_methods=["merge"]` and intentionally omits linear-history (the develop → main merge commit is non-linear by design). + +## Develop → Main Promotion + +Use the **"Create a merge commit"** option on develop → main PRs. Repo rulesets are split: PRs into `develop` are squash-only (linear history); PRs into `main` are merge-commit only. Clicking "Create a merge commit" on a develop → main PR produces a merge commit on main whose second parent is develop's tip — so develop becomes a real ancestor of main, and the *next* develop → main PR has a clean merge base (no recurring conflicts, no behind-base churn). + +Under any squash-only setup this would be a recurring pain point: each develop → main squash drops develop's ancestry and forces a per-cycle admin-bypass merge commit on develop to resync. With merge-commit on main, that resync is unnecessary — main's history shows one merge commit per release (a feature, not a defect: each promotion is visible as a single auditable node), and develop stays linear. + +## Release flow + +PlexCleaner is a "pull" project: consumers (`docker pull ptr727/plexcleaner:latest`, `docker pull ptr727/plexcleaner:develop`, GitHub Releases) track both branches. **Both `main` and `develop` auto-publish on every push** — there is no manual `workflow_dispatch` gate. + +[publish-release.yml](.github/workflows/publish-release.yml) drives both prereleases and stable releases off the same [build-release-task.yml](.github/workflows/build-release-task.yml). It triggers on `push: [main, develop]`: + +- **Push to `develop`** — automatic prerelease. Merging any PR into `develop` (feature, bug fix, dependabot) calls [get-version-task.yml](.github/workflows/get-version-task.yml) for an NBGV-computed version like `3.16.42-g1a2b3c4` (because develop does not match `publicReleaseRefSpec` in [version.json](version.json)) and creates a GitHub Release with `prerelease: true`. The Docker image is tagged `develop` by [publish-periodic-docker-release.yml](.github/workflows/publish-periodic-docker-release.yml). +- **Push to `main`** — automatic stable release. NBGV produces a clean version like `3.16.42` and creates a GitHub Release with `prerelease: false`. The Docker image is tagged `latest`. + +Branch-aware logic lives in three places: + +- [build-release-task.yml](.github/workflows/build-release-task.yml) — `prerelease: ${{ github.ref_name != 'main' }}` and `target_commitish: ${{ github.sha }}` (the latter is critical: without it, softprops creates the tag against the repo's default branch, mis-tagging develop builds onto main's tip). +- [publish-periodic-docker-release.yml](.github/workflows/publish-periodic-docker-release.yml) — `tag: ${{ github.ref_name == 'main' && 'latest' || 'develop' }}`. + +Bot-merged PRs (Dependabot) trigger the publish workflows automatically because the merge-bot uses an App token — see the merge-bot section below. + +## Dependabot + +[.github/dependabot.yml](.github/dependabot.yml) targets **both `main` and `develop`** with two ecosystems each (`nuget`, `github-actions`), grouped per ecosystem, daily. The duplication is intentional: because both branches auto-publish, develop must not drift from main's dependency baseline. A NuGet major bump landing on develop should land on main on the next promotion cycle, not weeks later. + +Major NuGet bumps are not auto-merged by [merge-bot-pull-request.yml](.github/workflows/merge-bot-pull-request.yml) — they require human review. Major GitHub Actions bumps are auto-merged because the workflow execution itself is the validation surface. + +## GitHub Actions pinning + +Every third-party action in `.github/workflows/*.yml` is pinned to a full commit SHA with a trailing comment matching the upstream release tag, e.g. `uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`. The comment is whatever tag the action's repo actually publishes — typically `# vX.Y.Z`, but use `# v3` if upstream only publishes major-only tags (e.g. `addnab/docker-run-action`) and `# master` if the action ships only a moving branch (rare). Floating refs without a SHA (`@v6`, `@main`, `@master`) are never used. Local reusable workflows (`./.github/workflows/*.yml`) are referenced by path and don't need pinning. + +**Why:** Floating tags can be silently re-pointed by the action's owner (or by a compromised account) to malicious code; a SHA pin is immutable. Matching the comment to upstream's actual release tag (rather than fabricating one) lets dependabot rewrite both the SHA and the comment together when bumping. + +When adding a new `uses:` line, resolve the latest release's commit SHA (`gh api repos///releases/latest`) and copy its `tag_name` into the comment verbatim. Don't ship a floating tag and "pin it later". + +## Merge bot + +[merge-bot-pull-request.yml](.github/workflows/merge-bot-pull-request.yml) auto-merges Dependabot PRs. Two key design choices: + +- **Branch-aware merge method**: the script picks `--squash` for PRs targeting develop and `--merge` for PRs targeting main, matching each ruleset's `allowed_merge_methods`. An unknown base branch is a hard error. +- **App token, not GITHUB_TOKEN**: the merge step uses a token minted by `actions/create-github-app-token` from `CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` secrets. Pushes authored by `GITHUB_TOKEN` are blocked from triggering downstream workflows by GitHub's recursion guard; without the App token, a Dependabot merge to develop would silently skip `publish-release.yml` and `publish-periodic-docker-release.yml`, leaving the develop Docker tag and prerelease stale. + +The App secrets (`CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY`) must exist in **both** secret namespaces: Settings → Secrets and variables → **Actions**, and Settings → Secrets and variables → **Dependabot**. Since Sept 2021, GitHub injects only the Dependabot-namespace secrets when a Dependabot-authored `pull_request` event fires; the regular Actions namespace is not visible to that run. Without the Dependabot duplicate the App-token step gets empty inputs and merge-bot silently fails to auto-merge. (The trigger remains `pull_request`, not `pull_request_target` — the merge-bot doesn't check out PR code, but `pull_request` plus duplicated secrets is the simpler, less-permissive setup.) + ## Key Requirements for All Projects Derived from This Template ### Build & Quality Standards