diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6d256e56..aea473b9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,7 +7,9 @@ on: concurrency: group: docker-${{ github.event.workflow_run.head_sha }} - cancel-in-progress: true + # Never cancel mid-push: an interrupted multi-tag push can leave the + # registry with a partial tag set (e.g. :latest moved, :stable not). + cancel-in-progress: false permissions: contents: read @@ -19,15 +21,24 @@ env: jobs: docker: + # Same gate as publish.yml: workflow_run fires for every CI completion + # (including fork and same-repo PR runs) with this repo's token and + # packages:write. Only same-repo tag pushes may publish images; CI's + # push trigger matches main/stable/* and v* tags, so a head_branch + # starting with "v" is necessarily a tag run. if: >- github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 + # The docker build only reads the tree; keep the token out of it. + persist-credentials: false - name: Resolve release tag id: tag diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4290edbb..cb55902b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,9 @@ on: concurrency: group: publish-${{ github.event.workflow_run.head_sha }} - cancel-in-progress: true + # Never cancel a publish mid-upload: a half-uploaded release (sdist up, + # wheel missing) cannot be re-run cleanly because PyPI rejects duplicates. + cancel-in-progress: false permissions: contents: write @@ -15,7 +17,16 @@ permissions: jobs: publish: - if: github.event.workflow_run.conclusion == 'success' + # workflow_run fires for EVERY CI completion — including CI runs for + # pull_requests from forks — and always executes here with this repo's + # secrets, tokens, and the pypi environment. Gate to same-repo tag + # pushes only: CI's push trigger matches branches main/stable/* and + # tags v*, so a head_branch starting with "v" is necessarily a tag run. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest environment: pypi steps: @@ -23,6 +34,9 @@ jobs: with: ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 + # python -m build executes the tree's build backend; don't leave + # the contents:write token sitting in .git/config while it runs. + persist-credentials: false - name: Resolve release tag id: tag diff --git a/.github/workflows/vendor-js.yml b/.github/workflows/vendor-js.yml index 120164a7..644d2915 100644 --- a/.github/workflows/vendor-js.yml +++ b/.github/workflows/vendor-js.yml @@ -25,18 +25,30 @@ permissions: jobs: vendor-js: - if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch' + # Same-repo PRs only: this job checks out the PR head and pushes to it + # with contents:write, so it must never act on a fork's branch. + # Gate on the PR author (immutable), not github.actor (names whoever + # caused the latest event, which can be someone else re-running it). + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'renovate[bot]' && + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: Resolve PR head ref id: ref env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Branch names may contain shell metacharacters; pass via env, + # never interpolate ${{ }} into the script body. + HEAD_REF: ${{ github.head_ref }} + PR_NUMBER: ${{ inputs.pr_number }} run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName) + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + ref=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefName -q .headRefName) else - ref="${{ github.head_ref }}" + ref="$HEAD_REF" fi echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"