Skip to content

Harden GitHub workflows for trustless, fork-based collaboration#65

Merged
conradbzura merged 4 commits intomainfrom
64-harden-github-workflows
Mar 23, 2026
Merged

Harden GitHub workflows for trustless, fork-based collaboration#65
conradbzura merged 4 commits intomainfrom
64-harden-github-workflows

Conversation

@conradbzura
Copy link
Copy Markdown
Collaborator

@conradbzura conradbzura commented Mar 12, 2026

Summary

Harden all GitHub Actions workflows and composite actions for secure, fork-friendly collaboration. Pin every third-party action to its commit SHA, move all user-controllable ${{ }} expressions out of run: blocks into env: blocks to prevent expression injection, add persist-credentials: false to every checkout that does not need push access, scope token permissions per-job, pass the PyPI token via environment variable instead of CLI argument, add concurrency controls, and remove the unnecessary label-pr workflow entirely. — Closes #64

Proposed changes

1. Remove label-pr.yaml and add-label composite action

Delete .github/workflows/label-pr.yaml and .github/actions/add-label/action.yaml. The workflow passes secrets.MY_TOKEN on every PR event to add a convenience label — the attack surface is not worth the automation.

2. Pin all third-party actions to commit SHAs

Replace every mutable version-tag reference with its current commit SHA and a trailing version comment:

Action Tag SHA
actions/checkout v4 34e114876b0b11c390a56381ad16ebd13914f8d5
prefix-dev/setup-pixi v0.8.1 ba3bb36eb2066252b2363392b7739741bb777659
astral-sh/setup-uv v5 e58605a9b6da7c637471fab8847a5e5a6b8df081
actions/upload-artifact v4 ea165f8d65b6e75b540449e92b4886f43607fa02
actions/download-artifact v4 d3f86a106a0bac45b974a628896c90dbdf5c8093
sigstore/gh-action-sigstore-python v3.0.0 f514d46b907ebcd5bedc05145c03b69c1edd8b46

3. Prevent expression injection

Move every user-controllable GitHub context expression (github.base_ref, github.head_ref, github.workflow, github.event.inputs.*, inputs.version, matrix.secret_name) from inline run: interpolation into env: blocks, then reference them as quoted shell variables. Affected files:

  • publish-release.yamlgithub.base_ref, github.head_ref in the bump-version job's case statement; needs.bump-version.outputs.version in tag-version
  • cut-release.yamlgithub.event.inputs.release-type in shell; github.workflow in PR body construction; steps.cut-release-branch.outputs.version in tag push
  • sync-branches.yamlgithub.workflow, github.base_ref in PR body and title construction
  • validate-repo.yamlmatrix.secret_name in error message
  • publish-github-release/action.yamlinputs.version in gh release create and gh release upload
  • build-release/action.yamlinputs.version in uv build --out-dir

4. Add persist-credentials: false to all non-pushing checkouts

Every actions/checkout step that does not subsequently git push sets persist-credentials: false to prevent the token from leaking to later steps. This applies to:

  • run-tests.yaml
  • publish-release.yamlbuild-release, publish-github-release, publish-pypi-release jobs
  • cut-release.yamlverify-code-changes, build-release, publish-github-release, publish-pypi-release jobs
  • validate-repo.yaml

5. Pass PyPI token via environment variable

In publish-pypi-release/action.yaml, pass the token through an environment variable (PYPI_TOKEN) instead of a positional CLI argument (visible in /proc/*/cmdline). Update publish-distribution.sh to read $PYPI_TOKEN from the environment when no positional token is supplied.

6. Fix command injection in get-touched-files

Replace the unsafe eval "pathspec=('${{ inputs.pathspec }}')" with a safe IFS=' ' read -ra pattern using an env var.

7. Scope permissions and add concurrency controls

  • Add top-level permissions: {} to all workflows, with explicit per-job grants for only the permissions each job actually needs.
  • Add concurrency: blocks: cancel-in-progress: true for test workflows, cancel-in-progress: false for release/sync workflows.

8. Fix drop-release dependency completeness

Make drop-release depend on all publish jobs (publish-github-release, publish-pypi-release) in addition to build-release, with if: always() && !contains(needs.*.result, 'failure') to prevent deleting the release branch if publishing fails.

9. Add idempotency guard to sync-branches

Before creating a sync PR, check whether one already exists with gh pr list --base ... --head ... --state open. This prevents failures when the workflow re-runs on an already-synced merge.

Implementation plan

    • Delete .github/workflows/label-pr.yaml and .github/actions/add-label/action.yaml
    • Pin all third-party action references to commit SHAs across all workflow and composite action files
    • Move all user-controllable ${{ }} expressions from run: blocks into env: blocks across all workflows and composite actions
    • Add persist-credentials: false to every non-pushing actions/checkout step
    • Update publish-pypi-release/action.yaml to pass PyPI token via env var; update publish-distribution.sh to read $PYPI_TOKEN from the environment
    • Fix command injection in get-touched-files/action.yaml by replacing eval with safe input parsing
    • Scope permissions per-job and remove unused id-token: write grants
    • Add concurrency blocks to all workflows
    • Fix drop-release in publish-release.yaml to depend on all publish jobs
    • Add idempotency guard to sync-branches.yaml PR creation steps

The label-pr workflow and its add-label composite action are no longer
used and add unnecessary attack surface via the MY_TOKEN secret and
overly broad id-token: write permission.
@conradbzura conradbzura marked this pull request as ready for review March 13, 2026 20:25
@conradbzura conradbzura self-assigned this Mar 13, 2026
Pin all third-party actions to immutable commit SHAs to prevent
supply-chain attacks via tag mutation.

Move user-controllable GitHub expressions (github.base_ref,
github.head_ref, github.event.inputs.*, inputs.version,
matrix.secret_name, step outputs, github.workflow) from run: blocks
into env: blocks to eliminate expression injection vectors.

Fix command injection in get-touched-files action by replacing the
unsafe eval of the pathspec input with IFS-based word splitting.

Add persist-credentials: false to all non-pushing checkout steps to
prevent token leakage.

Scope job permissions to least privilege by adding top-level
permissions: {} and explicit per-job permissions where needed.

Pass the PyPI token via PYPI_TOKEN environment variable instead of a
CLI argument to avoid exposing it in process listings. Update
publish-distribution.sh to check the PYPI_TOKEN env var before
falling back to the keychain.

Add concurrency blocks to all workflows: cancel-in-progress for test
runs, no-cancel for release and sync workflows.

Fix drop-release job to depend on all downstream jobs with an
always() guard so the release branch is only deleted after all
publish steps complete.

Add idempotency guard to sync-branches to skip PR creation when a
sync PR already exists.
@conradbzura conradbzura force-pushed the 64-harden-github-workflows branch from 2136241 to 8618a52 Compare March 23, 2026 14:06
@conradbzura conradbzura merged commit f82e466 into main Mar 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden GitHub workflows for trustless, fork-based collaboration

1 participant