diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index 6f12a28a..471ec3cc 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@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 with: name: lastbuild label: "Last Build" diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index f43a9022..e103c23f 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -24,28 +24,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@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 with: platforms: linux/amd64,linux/arm64 - name: Setup Buildx step - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.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@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Docker build and push step - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: ${{ inputs.push }} diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index 9ea6dfd3..c9724930 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@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 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@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: pattern: publish-* merge-multiple: true @@ -72,7 +72,7 @@ jobs: - name: Upload build artifacts step id: artifact-upload-step - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: executable-build path: ${{ runner.temp }}/Console.7z diff --git a/.github/workflows/build-nugetlibrary-task.yml b/.github/workflows/build-nugetlibrary-task.yml index a0b9413f..cfd39777 100644 --- a/.github/workflows/build-nugetlibrary-task.yml +++ b/.github/workflows/build-nugetlibrary-task.yml @@ -29,12 +29,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 NuGet library project step run: | @@ -63,7 +63,7 @@ jobs: - name: Upload build artifacts step id: artifact-upload-step - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: nugetlibrary-build path: ${{ runner.temp }}/NuGetLibrary.7z diff --git a/.github/workflows/build-pypilibrary-task.yml b/.github/workflows/build-pypilibrary-task.yml index 60e926e4..7b92e201 100644 --- a/.github/workflows/build-pypilibrary-task.yml +++ b/.github/workflows/build-pypilibrary-task.yml @@ -1,149 +1,149 @@ -name: Build PyPI library task - -# This reusable workflow only builds the PyPI library and uploads the -# wheel + sdist as a workflow-run artifact. It does NOT publish to PyPI. -# Publishing happens directly in `publish-release.yml` so that the -# `id-token: write` permission required by Trusted Publishing is granted -# at the entry-point job, not propagated through a reusable-workflow -# chain (which would require every caller — including `test-release-task.yml` -# during PR validation — to also grant id-token write, even when no -# publishing happens). - -on: - workflow_call: - outputs: - artifact-name: - value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} - artifact-id: - value: ${{ jobs.build-pypilibrary.outputs.artifact-id }} - -jobs: - - get-version: - name: Get version information job - uses: ./.github/workflows/get-version-task.yml - - build-pypilibrary: - name: Build PyPI library project job - runs-on: ubuntu-latest - needs: [get-version] - defaults: - run: - working-directory: ./PyPiLibrary - outputs: - artifact-name: pypilibrary-build - artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} - - steps: - - - name: Checkout code step - uses: actions/checkout@v6 - - - name: Setup uv step - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - # Pin uv to the same version as `.devcontainer/python/post-create.sh` - # (UV_VERSION) so CI and local devcontainer behavior cannot drift - # — same uv resolves the same lockfile the same way. Bump in lock- - # step with the devcontainer pin. - version: "0.11.8" - enable-cache: true - cache-dependency-glob: "PyPiLibrary/uv.lock" - - - name: Sync dependencies step - run: uv sync --all-groups --frozen - - - name: Lint with ruff step - run: uv run ruff check - - - name: Verify formatting with ruff step - run: uv run ruff format --check - - - name: Type check with pyright step - run: uv run pyright - - - name: Run pytest step - run: uv run pytest - - # Compute the PEP 440 version string for this build: - # refs/heads/main -> AssemblyFileVersion as-is - # (`Major.Minor.Patch.BuildNumber`). PEP 440 - # treats this 4-segment numeric form as a - # release. - # refs/heads/develop -> `${AssemblyFileVersion}.dev0` - # (`Major.Minor.Patch.BuildNumber.dev0`). - # The BuildNumber stays in the release - # segment so develop's release segment - # (which grows past main's after every - # new commit) compares higher than main's - # under PEP 440 ordering — so `pip install - # --pre ` picks the develop dev - # build, while default `pip install ` - # filters the dev suffix and picks the - # main release. The `.dev0` literal is a - # constant because BuildNumber alone - # already differentiates each develop - # push (NBGV BuildNumber increments per - # commit), so we don't need a second - # counter in the dev segment. - # - # Edge case: in the window between a - # release merge to main and the next - # commit on develop, develop's - # BuildNumber equals main's (or is one - # lower) — `--pre` will still resolve to - # the main release until a new develop - # commit lands. This is accepted as a - # small, self-healing gap. - # other refs (PR validation via test-release-task, feature - # branches) -> AssemblyFileVersion as-is. These - # refs never publish; we just need a PEP 440 valid - # string for `uv build`. - - name: Compute PyPI version step - id: pypiver - run: | - set -euo pipefail - if [[ "$GITHUB_REF" == "refs/heads/develop" ]]; then - version="${AFV}.dev0" - else - version="$AFV" - fi - echo "PyPI version for $GITHUB_REF: $version" - echo "version=$version" >> "$GITHUB_OUTPUT" - env: - AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }} - - # Replace the `__version__` line in `_version.py` (which ships - # hardcoded "0.0.0" so local `uv build` works without CI) with the - # branch-aware PEP 440 version computed above. `sed -i` replaces - # the line in place so the module docstring and any future metadata - # in the file survive into the published wheel / sdist. - # `_version.py` is the single source `hatchling` reads via the - # `[tool.hatch.version]` path in pyproject.toml. Done AFTER tests - # so the test that asserts `__version__` is a non-empty string - # isn't affected. On main, the PyPI version string equals the .NET - # assemblies' `FileVersion` stamp (= NBGV `AssemblyFileVersion`, - # `M.N.P.B`); on develop, the PyPI version is `M.N.P.B.dev0` — - # numerically the same `FileVersion` with a trailing `.dev0` - # prerelease marker. .NET keeps the bare `FileVersion`, and - # NuGet/Docker use NBGV `SemVer2`, so strings are not byte- - # identical across artifacts on either channel. All four still - # derive from the same NBGV computation per commit (main pushes - # publish release versions; develop pushes publish PEP 440 dev - # releases / NBGV prereleases). - - name: Write version into _version.py step - run: | - set -euo pipefail - sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py - env: - VERSION: ${{ steps.pypiver.outputs.version }} - - - name: Build sdist and wheel step - run: uv build - - - name: Upload build artifacts step - id: artifact-upload-step - uses: actions/upload-artifact@v6 - with: - name: pypilibrary-build - path: PyPiLibrary/dist/* +name: Build PyPI library task + +# This reusable workflow only builds the PyPI library and uploads the +# wheel + sdist as a workflow-run artifact. It does NOT publish to PyPI. +# Publishing happens directly in `publish-release.yml` so that the +# `id-token: write` permission required by Trusted Publishing is granted +# at the entry-point job, not propagated through a reusable-workflow +# chain (which would require every caller — including `test-release-task.yml` +# during PR validation — to also grant id-token write, even when no +# publishing happens). + +on: + workflow_call: + outputs: + artifact-name: + value: ${{ jobs.build-pypilibrary.outputs.artifact-name }} + artifact-id: + value: ${{ jobs.build-pypilibrary.outputs.artifact-id }} + +jobs: + + get-version: + name: Get version information job + uses: ./.github/workflows/get-version-task.yml + + build-pypilibrary: + name: Build PyPI library project job + runs-on: ubuntu-latest + needs: [get-version] + defaults: + run: + working-directory: ./PyPiLibrary + outputs: + artifact-name: pypilibrary-build + artifact-id: ${{ steps.artifact-upload-step.outputs.artifact-id }} + + steps: + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup uv step + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + # Pin uv to the same version as `.devcontainer/python/post-create.sh` + # (UV_VERSION) so CI and local devcontainer behavior cannot drift + # — same uv resolves the same lockfile the same way. Bump in lock- + # step with the devcontainer pin. + version: "0.11.8" + enable-cache: true + cache-dependency-glob: "PyPiLibrary/uv.lock" + + - name: Sync dependencies step + run: uv sync --all-groups --frozen + + - name: Lint with ruff step + run: uv run ruff check + + - name: Verify formatting with ruff step + run: uv run ruff format --check + + - name: Type check with pyright step + run: uv run pyright + + - name: Run pytest step + run: uv run pytest + + # Compute the PEP 440 version string for this build: + # refs/heads/main -> AssemblyFileVersion as-is + # (`Major.Minor.Patch.BuildNumber`). PEP 440 + # treats this 4-segment numeric form as a + # release. + # refs/heads/develop -> `${AssemblyFileVersion}.dev0` + # (`Major.Minor.Patch.BuildNumber.dev0`). + # The BuildNumber stays in the release + # segment so develop's release segment + # (which grows past main's after every + # new commit) compares higher than main's + # under PEP 440 ordering — so `pip install + # --pre ` picks the develop dev + # build, while default `pip install ` + # filters the dev suffix and picks the + # main release. The `.dev0` literal is a + # constant because BuildNumber alone + # already differentiates each develop + # push (NBGV BuildNumber increments per + # commit), so we don't need a second + # counter in the dev segment. + # + # Edge case: in the window between a + # release merge to main and the next + # commit on develop, develop's + # BuildNumber equals main's (or is one + # lower) — `--pre` will still resolve to + # the main release until a new develop + # commit lands. This is accepted as a + # small, self-healing gap. + # other refs (PR validation via test-release-task, feature + # branches) -> AssemblyFileVersion as-is. These + # refs never publish; we just need a PEP 440 valid + # string for `uv build`. + - name: Compute PyPI version step + id: pypiver + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == "refs/heads/develop" ]]; then + version="${AFV}.dev0" + else + version="$AFV" + fi + echo "PyPI version for $GITHUB_REF: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + env: + AFV: ${{ needs.get-version.outputs.AssemblyFileVersion }} + + # Replace the `__version__` line in `_version.py` (which ships + # hardcoded "0.0.0" so local `uv build` works without CI) with the + # branch-aware PEP 440 version computed above. `sed -i` replaces + # the line in place so the module docstring and any future metadata + # in the file survive into the published wheel / sdist. + # `_version.py` is the single source `hatchling` reads via the + # `[tool.hatch.version]` path in pyproject.toml. Done AFTER tests + # so the test that asserts `__version__` is a non-empty string + # isn't affected. On main, the PyPI version string equals the .NET + # assemblies' `FileVersion` stamp (= NBGV `AssemblyFileVersion`, + # `M.N.P.B`); on develop, the PyPI version is `M.N.P.B.dev0` — + # numerically the same `FileVersion` with a trailing `.dev0` + # prerelease marker. .NET keeps the bare `FileVersion`, and + # NuGet/Docker use NBGV `SemVer2`, so strings are not byte- + # identical across artifacts on either channel. All four still + # derive from the same NBGV computation per commit (main pushes + # publish release versions; develop pushes publish PEP 440 dev + # releases / NBGV prereleases). + - name: Write version into _version.py step + run: | + set -euo pipefail + sed -i 's/^__version__ = .*/__version__ = "'"$VERSION"'"/' src/ptr727_projecttemplate_library/_version.py + env: + VERSION: ${{ steps.pypiver.outputs.version }} + + - name: Build sdist and wheel step + run: uv build + + - name: Upload build artifacts step + id: artifact-upload-step + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: pypilibrary-build + path: PyPiLibrary/dist/* diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index bf5d90c8..cfe9b9ae 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -65,16 +65,16 @@ jobs: steps: - name: Checkout code step - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download NuGet library build artifacts step - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: artifact-ids: ${{ needs.build-nugetlibrary.outputs.artifact-id }} path: ./Publish - name: Download executable build artifacts step - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: artifact-ids: ${{ needs.build-executable.outputs.artifact-id }} path: ./Publish diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 2677cc9f..24e1a7ca 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -27,12 +27,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 with: fetch-depth: 0 diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 7aaaf7c2..2c274a13 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,143 +1,205 @@ -name: Merge bot pull request action - -# Token strategy: -# Every merge job in this workflow uses the GitHub App token -# (`actions/create-github-app-token`). The resulting merge push is -# committed by the App, which fires downstream workflows on develop and -# main. Pushes authored by `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. The App-token -# path also removes the close/reopen dance previously used by codegen -# PRs created under `GITHUB_TOKEN` to nudge the auto-merge workflow. -# -# Merge method: -# Each merge step picks `--squash` or `--merge` from the PR's base ref -# so the form matches that branch's ruleset (`develop` allows only -# squash, `main` allows only merge commits — see AGENTS.md "Branching -# Model"). A mismatch fails `enablePullRequestAutoMerge` with "Merge -# method ... is not allowed on this repository". Both Dependabot and -# codegen open parallel PRs against `main` and `develop` (see the -# AGENTS.md "Branching Model" dual-target bot section), so both jobs -# below use a `case` statement to dispatch the merge method by base -# ref. Dependabot security update PRs (always against `main`) flow -# through the same code path. - -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 - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.CODEGEN_APP_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Get dependabot metadata step - id: metadata - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - # Skip semver-major NuGet bumps: majors can build cleanly but break - # runtime behavior, so they should land via human review. Other - # ecosystems' majors (github-actions, uv) are usually safe and 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 }} - - merge-codegen: - name: Merge codegen pull request job - runs-on: ubuntu-latest - # Restrict to codegen PRs that originate from the App in this - # repository. Codegen runs in a matrix over `main` and `develop`, - # so two head refs are valid: `codegen-main` (always targets `main`) - # and `codegen-develop` (always targets `develop`). The head/base - # pairing is enforced strictly so a misconfigured workflow can't, - # for example, sneak a `codegen-develop` branch into `main`. - # Both the PR author AND the event actor must be the App: the - # author check stops human-opened PRs that happen to target a - # `codegen-*` branch from auto-merging; the actor check stops - # this job from re-invoking `gh pr merge --auto` on a - # `synchronize` event a maintainer triggered. - # - # Limitation worth knowing: the actor check does NOT disable - # auto-merge if it was already enabled by the initial bot-driven - # `opened` event. Once auto-merge is on, every commit that - # passes CI will land — including a maintainer's. To edit a - # codegen PR safely, run `gh pr merge --disable-auto ` (or - # click "Disable auto-merge" in the GitHub UI) BEFORE pushing, - # then re-enable it manually when ready. - if: >- - github.event.pull_request.user.login == 'ptr727-codegen[bot]' && - github.actor == 'ptr727-codegen[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - ( - (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') || - (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop') - ) - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.CODEGEN_APP_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Merge pull request step - 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 + +# Three-job model: +# 1. `merge-dependabot` / `merge-codegen` run on `opened` and `reopened` +# events only. They enable auto-merge via `gh pr merge --auto` once +# per PR. Restricting to open/reopen (skipping `synchronize`) is what +# makes step 3 below stick — if these jobs re-ran on every +# `synchronize`, they'd undo a maintainer-triggered disable. +# 2. The merge method (`--squash` vs `--merge`) is dispatched by a +# `case` statement on `pull_request.base.ref` so the form matches +# each branch's ruleset (develop = squash-only, main = merge-only, +# see AGENTS.md "Branching Model"). Both Dependabot and codegen +# open parallel PRs against both branches; Dependabot security +# updates always target `main` and flow through the same code path. +# 3. `disable-auto-merge-on-maintainer-push` runs on `synchronize` +# events against bot-authored PRs when the event actor is NOT the +# same bot — i.e. a maintainer pushed commits to a bot PR. It +# calls `gh pr merge --disable-auto` so the maintainer's commits +# don't auto-merge along with the bot's content. The maintainer +# re-enables auto-merge manually (UI or `gh pr merge --auto`) +# when ready. +# +# Token strategy: +# Every job uses an App token (`actions/create-github-app-token`). +# The resulting push is committed by the App, which fires downstream +# workflows on develop and main. `GITHUB_TOKEN`-authored pushes are +# blocked from triggering further workflow runs by GitHub's recursion +# guard, which would silently skip `publish-release.yml` on the merge +# commit. The App-token path also removes the close/reopen dance +# previously used by codegen PRs created under `GITHUB_TOKEN` to nudge +# the auto-merge workflow. The disable job needs an App token too: +# even though the event actor is a maintainer, the workflow context +# on a Dependabot PR runs with Dependabot's restricted secrets +# regardless of actor, so plain `GITHUB_TOKEN` would be read-only. + +on: + pull_request: + types: [opened, reopened, synchronize] + +# `cancel-in-progress: false` is load-bearing. The three-job model +# (enable on opened/reopened, disable on maintainer-triggered +# synchronize) relies on those events running to completion in arrival +# order. With cancel-in-progress: true, a fast follow-up synchronize +# (e.g. a Dependabot rebase right after PR open) would cancel the +# in-flight `opened` run before it reached `gh pr merge --auto`, and +# the new synchronize run skips the enable jobs (opened/reopened +# filter), leaving auto-merge never enabled. Queueing instead of +# cancelling makes the final state deterministic: opened enables, +# then any subsequent synchronize disables (if maintainer) or no-ops +# (if bot). Action-aware grouping has its own race (opened finishing +# after a maintainer synchronize would re-enable auto-merge), so we +# keep a single group and just disable cancellation. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +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. Only runs on `opened` / `reopened` events so the auto-merge + # enable happens once per PR; the `disable-auto-merge-on-maintainer-push` + # job below is what disables auto-merge when a maintainer pushes to a + # Dependabot branch. Skipping `synchronize` here is what keeps that + # disable 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@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.CODEGEN_APP_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Get dependabot metadata step + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # Skip semver-major NuGet bumps: majors can build cleanly but break + # runtime behavior, so they should land via human review. Other + # ecosystems' majors (github-actions, uv) are usually safe and 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 }} + + merge-codegen: + name: Merge codegen pull request job + runs-on: ubuntu-latest + # Restrict to codegen PRs that originate from the App in this + # repository. Codegen runs in a matrix over `main` and `develop`, + # so two head refs are valid: `codegen-main` (always targets `main`) + # and `codegen-develop` (always targets `develop`). The head/base + # pairing is enforced strictly so a misconfigured workflow can't, + # for example, sneak a `codegen-develop` branch into `main`. + # Only runs on `opened` / `reopened` events so the auto-merge enable + # happens once per PR; the `disable-auto-merge-on-maintainer-push` + # job below is what disables auto-merge when a maintainer pushes to a + # codegen branch. Skipping `synchronize` here is what keeps that + # disable sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'ptr727-codegen[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event.pull_request.head.ref == 'codegen-main' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'codegen-develop' && github.event.pull_request.base.ref == 'develop') + ) + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.CODEGEN_APP_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + 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 on `synchronize` events against bot-authored PRs (Dependabot + # or codegen) when the event actor is NOT the same bot — i.e. a + # maintainer pushed commits to the bot's branch. Disables auto-merge + # so the maintainer's commits don't auto-merge along with the bot's + # content. The maintainer re-enables auto-merge manually when ready + # (UI button, or `gh pr merge --auto `). + # + # `gh pr merge --disable-auto` is idempotent — calling it on a PR + # that already has auto-merge disabled is a no-op. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'ptr727-codegen[bot]' + ) && + github.actor != github.event.pull_request.user.login + permissions: + pull-requests: write + + steps: + + - name: Generate GitHub App token step + # App token rather than GITHUB_TOKEN: on a Dependabot PR the + # workflow context runs with Dependabot's restricted secrets + # regardless of who triggered the event (GitHub gates by PR + # origin, not by event actor), and the restricted GITHUB_TOKEN + # is read-only. Same App token pattern as the other merge jobs. + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.CODEGEN_APP_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-release.yml b/.github/workflows/publish-release.yml index 63842a37..1821d717 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -59,7 +59,7 @@ jobs: steps: - name: Download PyPI library build artifacts step - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: pypilibrary-build path: ./dist diff --git a/.github/workflows/run-codegen-pull-request-task.yml b/.github/workflows/run-codegen-pull-request-task.yml index 4e284c34..daf3d675 100644 --- a/.github/workflows/run-codegen-pull-request-task.yml +++ b/.github/workflows/run-codegen-pull-request-task.yml @@ -1,93 +1,93 @@ -name: Run codegen and pull request task - -# Runs codegen against `main` and `develop` in parallel via a matrix, -# opens a PR against each base (`codegen-main` branch → main, -# `codegen-develop` branch → develop). The merge-bot auto-merges -# either PR independently. This keeps both branches current on -# generated content (date stamps, API-derived data, etc.) without -# either branch falling behind the other and without main → develop -# back-merges (see AGENTS.md "Branching Model" for the forward-only -# develop invariant). - -on: - workflow_call: - secrets: - # GitHub App credentials to generate an installation token - CODEGEN_APP_ID: - required: true - CODEGEN_APP_PRIVATE_KEY: - required: true - # API Ninjas API key - NINJA_API_KEY: - required: true - -jobs: - - codegen: - name: Run ${{ matrix.target.ref }} codegen and pull request job - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - strategy: - # Each branch gets its own parallel codegen run + PR. If one - # branch's PR fails (CI, conflicts, etc.) the other is unaffected. - fail-fast: false - matrix: - target: - - ref: main - branch: codegen-main - - ref: develop - branch: codegen-develop - - steps: - - - name: Generate GitHub App token step - # The App-token-driven PR open fires `pull_request` workflow events - # directly. `GITHUB_TOKEN`-driven PR opens do not (GitHub's recursion - # guard), which previously required a close/reopen dance under a PAT - # to nudge the auto-merge workflow — that dance is gone. - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.CODEGEN_APP_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@v6 - with: - ref: ${{ matrix.target.ref }} - token: ${{ steps.app-token.outputs.token }} - - - name: Run codegen step - run: | - set -euo pipefail - dotnet run --project ./CodeGen/CodeGen.csproj -- \ - --codepath ./CodeGen \ - --apikey "${{ secrets.NINJA_API_KEY }}" - - - name: Format code step - run: | - set -euo pipefail - dotnet tool restore - dotnet csharpier format --log-level=debug . - git status - - - name: Create pull request step - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - id: cpr - with: - # App token: triggers pull_request workflow events directly, creates verified commits as the app - token: ${{ steps.app-token.outputs.token }} - base: ${{ matrix.target.ref }} - branch: ${{ matrix.target.branch }} - title: 'Update codegen files' - body: 'This PR updates the codegen files.' - commit-message: 'Update codegen files' - delete-branch: true - sign-commits: true +name: Run codegen and pull request task + +# Runs codegen against `main` and `develop` in parallel via a matrix, +# opens a PR against each base (`codegen-main` branch → main, +# `codegen-develop` branch → develop). The merge-bot auto-merges +# either PR independently. This keeps both branches current on +# generated content (date stamps, API-derived data, etc.) without +# either branch falling behind the other and without main → develop +# back-merges (see AGENTS.md "Branching Model" for the forward-only +# develop invariant). + +on: + workflow_call: + secrets: + # GitHub App credentials to generate an installation token + CODEGEN_APP_ID: + required: true + CODEGEN_APP_PRIVATE_KEY: + required: true + # API Ninjas API key + NINJA_API_KEY: + required: true + +jobs: + + codegen: + name: Run ${{ matrix.target.ref }} codegen and pull request job + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + strategy: + # Each branch gets its own parallel codegen run + PR. If one + # branch's PR fails (CI, conflicts, etc.) the other is unaffected. + fail-fast: false + matrix: + target: + - ref: main + branch: codegen-main + - ref: develop + branch: codegen-develop + + steps: + + - name: Generate GitHub App token step + # The App-token-driven PR open fires `pull_request` workflow events + # directly. `GITHUB_TOKEN`-driven PR opens do not (GitHub's recursion + # guard), which previously required a close/reopen dance under a PAT + # to nudge the auto-merge workflow — that dance is gone. + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + with: + app-id: ${{ secrets.CODEGEN_APP_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ matrix.target.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Run codegen step + run: | + set -euo pipefail + dotnet run --project ./CodeGen/CodeGen.csproj -- \ + --codepath ./CodeGen \ + --apikey "${{ secrets.NINJA_API_KEY }}" + + - name: Format code step + run: | + set -euo pipefail + dotnet tool restore + dotnet csharpier format --log-level=debug . + git status + + - name: Create pull request step + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + id: cpr + with: + # App token: triggers pull_request workflow events directly, creates verified commits as the app + token: ${{ steps.app-token.outputs.token }} + base: ${{ matrix.target.ref }} + branch: ${{ matrix.target.branch }} + title: 'Update codegen files' + body: 'This PR updates the codegen files.' + commit-message: 'Update codegen files' + delete-branch: true + sign-commits: true diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index 0623b651..8508744a 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -1,22 +1,27 @@ -name: Run weekly CodeGen and Pull Request action - -on: - workflow_dispatch: - schedule: - # Run weekly on Mondays at 02:00 UTC. - - cron: '0 2 * * MON' - -concurrency: - # Workflow always checks out and targets main/codegen - group: codegen-main - cancel-in-progress: true - -jobs: - - run-codegen: - name: Run codegen and pull request job - uses: ./.github/workflows/run-codegen-pull-request-task.yml - secrets: inherit - permissions: - contents: write - pull-requests: write +name: Run weekly CodeGen and Pull Request action + +on: + workflow_dispatch: + schedule: + # Run weekly on Mondays at 02:00 UTC. + - cron: '0 2 * * MON' + +concurrency: + # Standard AGENTS.md "Concurrency" convention. Scheduled runs always + # have `github.ref == refs/heads/` (cron's default), + # and the reusable workflow `run-codegen-pull-request-task.yml` then + # matrixes over both `main` and `develop` internally — so a single + # group per workflow+ref serializes all matrix legs of all scheduled + # / dispatched runs in practice. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + run-codegen: + name: Run codegen and pull request job + uses: ./.github/workflows/run-codegen-pull-request-task.yml + secrets: inherit + permissions: + contents: write + pull-requests: write diff --git a/.github/workflows/test-release-task.yml b/.github/workflows/test-release-task.yml index d58bbb58..bc36044d 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: Restore .NET local tools step run: dotnet tool restore diff --git a/AGENTS.md b/AGENTS.md index cd737d41..89ae840f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Treat this file as authoritative for everything else; don't restate its rules el - All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. - **`develop` is forward-only — no `main → develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` (`b9b0447`, `410ba56`, `ffb9e64`, `5ce95cf`, etc.) predate this rule and must not be repeated. - **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`.github/workflows/run-codegen-pull-request-task.yml`](./.github/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-develop`. 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). 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. +- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `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 auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. - **Why parallel dual-target rather than develop-only with eventual flow-through:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. ## Pull Request Title and Commit Message Conventions @@ -108,13 +109,13 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul ## Workflow YAML Conventions -These conventions describe the target state. New and modified workflows must respect them; existing workflows are migrated opportunistically when they're being touched for other reasons. Don't open a PR purely to apply these rules across the repo — the churn isn't worth it. +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. Sweep PRs that apply a rule everywhere are welcome when a rule changes. -- **Action pinning**: pin third-party actions to a commit SHA with a trailing `# vX.Y.Z` comment so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. First-party `actions/*` are encouraged but not required to follow the same convention. +- **Action pinning**: pin **every** action — first-party (`actions/*`) and third-party — to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA — pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./.github/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade — the rationale is documented inline in that workflow. - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do — `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim — renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exception**: [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order. Cancellation would leave auto-merge in an inconsistent state. The rationale is recorded inline in that workflow's header comment. - **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` — fail fast, fail on undefined vars, fail on a failed pipe segment. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks — one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms — `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. diff --git a/README.md b/README.md index 9c5e6584..271eb94f 100644 --- a/README.md +++ b/README.md @@ -456,10 +456,11 @@ Licensed under the [MIT License][license-link]\ - If the codegen workflows require additional secrets (e.g. third-party API keys), register them in the Actions store; if a Dependabot-triggered workflow ever needs them, register them in the Dependabot store too. - The App token is used by **both** the codegen workflow (`run-codegen-pull-request-task.yml`) **and** every job in `merge-bot-pull-request.yml`. App-authored pushes/PRs trigger downstream `pull_request` and `push` workflow events directly — unlike `GITHUB_TOKEN`-authored events, which are blocked by GitHub's recursion guard. This is why `publish-release.yml` fires on the merge commit after Dependabot or codegen auto-merge, and why the codegen workflow no longer needs the legacy close/reopen dance to trigger auto-merge. - The codegen auto-merge condition in `merge-bot-pull-request.yml` (`merge-codegen` job) requires: + - **Event is `opened` or `reopened`** — auto-merge is enabled once per PR at open time; subsequent `synchronize` events do not re-enable. This is what lets the `disable-auto-merge-on-maintainer-push` safeguard (below) stick. - `github.event.pull_request.user.login == 'ptr727-codegen[bot]'` — PR was opened by the App. - - `github.actor == 'ptr727-codegen[bot]'` — the event was triggered by the App. This stops the job from **re-invoking `gh pr merge --auto`** on a maintainer-triggered `synchronize`, but **does not disable auto-merge once it was already enabled by the initial bot-driven `opened` event**. If a maintainer pushes commits to a codegen PR with auto-merge already on, the next CI pass will merge them. To edit a codegen PR safely, disable auto-merge first via `gh pr merge --disable-auto ` (or the GitHub UI button) before pushing. - `github.event.pull_request.head.repo.full_name == github.repository` — PR is from this repo (not a fork). - **Strict head/base pairing** — `(head.ref == 'codegen-main' && base.ref == 'main') || (head.ref == 'codegen-develop' && base.ref == 'develop')`. Codegen runs as a matrix opening one PR per branch; this pairing prevents a misconfigured workflow from sneaking a `codegen-develop` branch into `main` or vice versa. + - The `disable-auto-merge-on-maintainer-push` job in `merge-bot-pull-request.yml` runs on `synchronize` events against bot-authored PRs (Dependabot or codegen) when the event actor is NOT the same bot — i.e. a maintainer pushed commits. It calls `gh pr merge --disable-auto` so the maintainer's commits don't auto-merge along with the bot's content. Re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. Codegen targets `main` AND `develop` in parallel (matrix in `run-codegen-pull-request-task.yml`), so generated content lands on both branches independently without any back-merging. See [AGENTS.md "Branching Model"](./AGENTS.md#branching-model) for why this dual-target pattern beats develop-only-with-flow-through.