diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 35521fd827..bda34ffc02 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -6,8 +6,17 @@ permissions: on: schedule: - cron: '0 10 * * 6' # everyday at 10am - push: - branches: [main] + # No `push: branches: [main]` here. `unit-tests.yml`'s own push trigger + # already runs the full matrix for this commit; calling it again below with + # `gate-only: true` re-ran the single-version "Gate" job a second time for + # the same tree (#715). Chaining off `Unit Tests`' completion means the gate + # never runs twice, and `conda-update` below just reads the verdict that + # already exists. `.event == 'push'` is checked in that job's `if` rather + # than here, because `workflow_run` fires for a pull request's completion + # too and a `branches:` filter here would be the only thing stopping that. + workflow_run: + workflows: ["Unit Tests"] + types: [completed] # This workflow commits and pushes, so a cancelled run can leave the bump # half-applied. Kept keyed on the ref, unlike the validation workflows: this @@ -22,8 +31,13 @@ concurrency: cancel-in-progress: false jobs: + # Only earns its runners on the schedule trigger now: `Unit Tests` has no + # schedule trigger of its own, so Saturday's cron run is the one case left + # where nothing else has already verified this tree. `workflow_run` carries + # its own verdict instead -- see `conda-update` below. unit-tests: name: Conda test gate + if: ${{ github.event_name != 'workflow_run' }} permissions: contents: read uses: ./.github/workflows/unit-tests.yml @@ -34,6 +48,25 @@ jobs: name: Update requirements.txt runs-on: ubuntu-latest needs: [ unit-tests ] + # `needs` cannot itself be conditional, so a skipped gate (`workflow_run`) + # has to be accepted explicitly -- by default this job would inherit the + # skip. `!cancelled()` rather than `always()` so that cancelling the run + # still cancels the update; a gate that actually failed still blocks. + # + # The `workflow_run` clause is the real gate for a push to main now: it + # reads the verdict `Unit Tests` already reached for this exact commit + # instead of re-running it. `.event == 'push'` and `.head_branch == 'main'` + # both matter -- `workflow_run` fires for *every* completion of that + # workflow, including one triggered by a pull request, and without this + # check a green PR would upload a conda distribution built from that PR's + # tree. + if: >- + !cancelled() && needs.unit-tests.result != 'failure' && + (github.event_name != 'workflow_run' || ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + )) concurrency: group: repository-maintenance cancel-in-progress: false @@ -47,6 +80,23 @@ jobs: - uses: actions/checkout@v7 with: + # Deliberately the default `github.sha` (main's tip), not + # `github.event.workflow_run.head_sha`. Pinning to the latter once + # ran here and CodeQL flagged it as "checkout of untrusted code in a + # privileged context" -- `workflow_run` can also be raised by a pull + # request's `Unit Tests` run, and that field would then be a fork's + # commit. The `if:` above already keeps that path from reaching this + # step, but making the ref itself always resolve to a trusted + # default-branch commit removes the pattern outright rather than + # relying on CodeQL tracing through the guard. The push below rebases + # onto main's actual tip before writing regardless, so pinning to the + # exact tested sha would have bought nothing here either. + # + # The push below re-points `origin` at an SSH remote and authenticates + # with the loaded deploy key, not with this checkout's credential -- + # so there is nothing here for a persisted token to do, and one less + # live credential for anything running in between to find. + persist-credentials: false fetch-depth: 0 - uses: actions/setup-python@v7 @@ -131,7 +181,16 @@ jobs: token: ${{ secrets.PYPCAPKIT }} fetch-depth: 0 + # `env:` rather than splicing `needs.*.outputs` straight into the script -- + # CodeQL flags the latter as code injection, since Actions substitutes + # `${{ }}` into the script text before the shell ever parses it, so a + # value containing shell metacharacters runs as script rather than data. + # Routing it through `env:` and reading `$PCAPKIT_VERSION`/`$PCAPKIT_BUILD` + # keeps it a shell variable, which the shell can only ever treat as data. - name: Create Tag + env: + PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} + PCAPKIT_BUILD: ${{ needs.conda-update.outputs.PCAPKIT_BUILD }} run: | set -x @@ -139,12 +198,15 @@ jobs: git config --local user.name "github-actions[bot]" git pull - git tag "conda-${{ needs.conda-update.outputs.PCAPKIT_VERSION }}+${{ needs.conda-update.outputs.PCAPKIT_BUILD }}" -m"Conda Build" + git tag "conda-${PCAPKIT_VERSION}+${PCAPKIT_BUILD}" -m"Conda Build" - name: Push Tag + env: + PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} + PCAPKIT_BUILD: ${{ needs.conda-update.outputs.PCAPKIT_BUILD }} run: | git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git - git push origin "conda-${{ needs.conda-update.outputs.PCAPKIT_VERSION }}+${{ needs.conda-update.outputs.PCAPKIT_BUILD }}" + git push origin "conda-${PCAPKIT_VERSION}+${PCAPKIT_BUILD}" conda-dist: name: Conda deployment (update) on ${{ matrix.os }} with Python ${{ matrix.python-version }} diff --git a/.github/workflows/cron-vendor.yml b/.github/workflows/cron-vendor.yml index 6106070e76..2b30994bb8 100644 --- a/.github/workflows/cron-vendor.yml +++ b/.github/workflows/cron-vendor.yml @@ -3,8 +3,25 @@ name: "Vendor Update" on: schedule: - cron: '0 10 * * 6' # everyday at 10am - push: - branches: [main] + # No `push: branches: [main]` here. `unit-tests.yml`'s own push trigger + # already runs the full matrix for this commit; calling it again below with + # `gate-only: true` re-ran the single-version "Gate" job a second time for + # the same tree (#715). Chaining off `Unit Tests`' completion means the gate + # never runs twice, and `vendor-update` below just reads the verdict that + # already exists. `.event == 'push'` is checked in that job's `if` rather + # than here, because `workflow_run` fires for a pull request's completion + # too and a `branches:` filter here would be the only thing stopping that. + workflow_run: + workflows: ["Unit Tests"] + types: [completed] + +# Least privilege by default now that `workflow_run` is in the trigger list -- +# that event can be raised by a pull request's `Unit Tests` run completing, so +# the ambient token this workflow gets should default to nothing rather than +# whatever the repository's default is. Both jobs below already declare their +# own narrower `permissions:`, so this changes nothing they rely on; it only +# closes the gap for anything added later that forgets to. +permissions: {} # This workflow commits and pushes, so a cancelled run can leave the bump # half-applied. Kept keyed on the ref, unlike the validation workflows: a dropped @@ -20,8 +37,13 @@ concurrency: cancel-in-progress: false jobs: + # Only earns its runners on the schedule trigger now: `Unit Tests` has no + # schedule trigger of its own, so Saturday's cron run is the one case left + # where nothing else has already verified this tree. `workflow_run` carries + # its own verdict instead -- see `vendor-update` below. unit-tests: name: Vendor test gate + if: ${{ github.event_name != 'workflow_run' }} permissions: contents: read uses: ./.github/workflows/unit-tests.yml @@ -31,6 +53,24 @@ jobs: vendor-update: runs-on: macos-latest needs: [ unit-tests ] + # `needs` cannot itself be conditional, so a skipped gate (`workflow_run`) + # has to be accepted explicitly -- by default this job would inherit the + # skip. `!cancelled()` rather than `always()` so that cancelling the run + # still cancels the update; a gate that actually failed still blocks. + # + # The `workflow_run` clause is the real gate for a push to main now: it + # reads the verdict `Unit Tests` already reached for this exact commit + # instead of re-running it. `.event == 'push'` and `.head_branch == 'main'` + # both matter -- `workflow_run` fires for *every* completion of that + # workflow, including one triggered by a pull request, and without this + # check a green PR would push a vendor-registry commit from that PR's tree. + if: >- + !cancelled() && needs.unit-tests.result != 'failure' && + (github.event_name != 'workflow_run' || ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + )) concurrency: group: repository-maintenance cancel-in-progress: false @@ -43,6 +83,23 @@ jobs: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} - uses: actions/checkout@v7 + with: + # Deliberately the default `github.sha` (main's tip), not + # `github.event.workflow_run.head_sha`. Pinning to the latter once + # ran here and CodeQL flagged it as "checkout of untrusted code in a + # privileged context" -- `workflow_run` can also be raised by a pull + # request's `Unit Tests` run, and that field would then be a fork's + # commit. The `if:` above already keeps that path from reaching this + # step, but making the ref itself always resolve to a trusted + # default-branch commit removes the pattern outright rather than + # relying on CodeQL tracing through the guard. The push below rebases + # onto main's actual tip before writing regardless, so pinning to the + # exact tested sha would have bought nothing here either. + # The push below re-points `origin` at an SSH remote and authenticates + # with the loaded deploy key, not with this checkout's credential -- + # so there is nothing here for a persisted token to do, and one less + # live credential for anything running in between to find. + persist-credentials: false - uses: actions/setup-python@v7 with: diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 5918b85d78..dfa546f00a 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,8 +1,17 @@ name: GitHub Pages on: - push: - branches: [main] + # No `push: branches: [main]` here. `unit-tests.yml`'s own push trigger already + # runs the full matrix for this commit; calling it again with `gate-only: true` + # below re-ran the single-version "Gate" job a second time for the same tree + # (#715). Chaining off `Unit Tests`' completion instead means the gate never + # runs twice, and this workflow simply waits for the verdict that already + # exists. `workflow_run` fires for *any* completion of that workflow, PR or + # push, so the jobs below filter on `.event == 'push'` themselves rather than + # trusting a `branches:` filter here to do it -- see the note on `deploy-pages`. + workflow_run: + workflows: ["Unit Tests"] + types: [completed] schedule: - cron: '0 2 * * 6' pull_request: @@ -25,13 +34,14 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Nothing is published on a pull request -- the Deploy step below is skipped - # -- so the gate only earns its runners when a real publish follows. The docs - # build still runs on pull requests, which is the part that actually - # validates a documentation change. + # Only earns its runners on the schedule trigger now: `Unit Tests` has no + # schedule trigger of its own, so Saturday's cron run is the one case left + # where nothing else has already verified this tree, and a gate is still + # needed. Pull requests never published anyway, and `workflow_run` carries + # its own verdict from the run that completed -- see `deploy-pages` below. unit-tests: name: Docs test gate - if: ${{ github.event_name != 'pull_request' }} + if: ${{ github.event_name != 'pull_request' && github.event_name != 'workflow_run' }} permissions: contents: read uses: ./.github/workflows/unit-tests.yml @@ -40,17 +50,42 @@ jobs: deploy-pages: needs: [ unit-tests ] - # `needs` cannot itself be conditional, so a skipped gate (pull requests) - # has to be accepted explicitly -- by default this job would inherit the - # skip. `!cancelled()` rather than `always()` so that cancelling the run - # still cancels the docs build; a gate that actually failed still blocks. - if: ${{ !cancelled() && needs.unit-tests.result != 'failure' }} + # `needs` cannot itself be conditional, so a skipped gate (pull requests, + # and now `workflow_run`) has to be accepted explicitly -- by default this + # job would inherit the skip. `!cancelled()` rather than `always()` so that + # cancelling the run still cancels the docs build; a gate that actually + # failed still blocks. + # + # The `workflow_run` clause is the real gate for a push to main now: it + # reads the verdict `Unit Tests` already reached for this exact commit + # instead of re-running it. `.event == 'push'` and `.head_branch == 'main'` + # both matter -- `workflow_run` fires for *every* completion of that + # workflow, including one triggered by a pull request, and without this + # check a green PR would publish docs from that PR's tree. + if: >- + !cancelled() && needs.unit-tests.result != 'failure' && + (github.event_name != 'workflow_run' || ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + )) concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. runs-on: macos-latest steps: - name: Checkout 🛎️ uses: actions/checkout@v7 with: + # Deliberately the default `github.sha` (main's tip), not + # `github.event.workflow_run.head_sha`. Pinning to the latter once + # ran here and CodeQL flagged it as "checkout of untrusted code in a + # privileged context" -- `workflow_run` can also be raised by a pull + # request's `Unit Tests` run, and that field would then be a fork's + # commit. The `if:` above already keeps that path from reaching this + # step, but making the ref itself always resolve to a trusted + # default-branch commit removes the pattern outright rather than + # relying on CodeQL tracing through the guard. `main`'s tip is also + # the *right* commit for docs, which should reflect current main, + # not a slightly older one -- so this is not a compromise here. persist-credentials: false - uses: actions/setup-python@v7