Harden actions against script injection and pin third-party actions#28
Merged
Conversation
Interpolating ${{ ... }} expressions directly into a run: body substitutes
the value into the script text before bash runs, so a value with shell
metacharacters (command substitution, ;, |, backticks) executes as code.
- mkdocs-deploy: read github.event.release.tag_name and the mike alias via
env instead of inline interpolation, and quote them.
- python-setup/pip: drop eval for the extras/groups install (build argv arrays)
and read install-groups/cache via env.
- python-setup/uv, pixi: read install-groups / activate-environment via env and
quote the value passed to pixi list.
- release/pypi: read package, package-manager, install-groups, repository-url and
skip-publish via env; build the twine --repository-url argument as an array.
- deploy-docs workflow: set the git user from github.actor via env.
The generated commands are unchanged.
…ia env - Pin softprops/action-gh-release to commit 3d0d988 (v3.0.2) instead of the mutable @V3 tag. This action receives the release token, so a moved tag would run untrusted code with it. Matches the SHA pinning already used for setup-uv and setup-pixi. - Configure the git user from github.actor via env rather than inline interpolation.
- test-mkdocs-deploy, test-release/github: default the workflow token to contents: read. The jobs push only to the local fake remote, so no write scope is needed. - test-release/pypi: pin astral-sh/setup-uv to the SHA already used by the uv action (v8.3.2) instead of the mutable @v7 tag.
…e tag Quoting RELEASE_TAG_VERSION in the mike deploy command (part of the injection hardening) exposed a latent bug: when the release tag resolves to empty, the previous unquoted expansion silently collapsed the empty word and shifted the mike alias into the version slot, deploying a bogus version. Quoted, it now passes an empty version and mike errors with '' is not a valid version. Add an explicit guard so a release trigger with no resolvable tag (no release-tag input and no github.event.release.tag_name) fails fast with a clear message, and convert the corresponding test into a negative test that asserts this failure.
4 tasks
The negative test previously asserted only outcome == failure, so it could go green even if the action failed for a reason other than the tag-resolution guard. Pair it with a positive control (same fixture/setup, WITH a tag) and assert that adding the tag is exactly what flips failure to success. Also drop the inert GITHUB_EVENT_NAME env (the guard reads github.event.release.tag_name from the real push event, not that variable).
mike deploys by pushing to the gh-pages branch over git (contents: write); it never calls the GitHub Pages deployment API, so pages: write was unused. Drop it for least privilege.
…lease Document that the reduced token scope is safe only because every job sets skip-github-release: 'true' (Releases API never called) and pushes go to the fake remote, so a future real-release job would need contents: write.
…njection (SEC-8)
Mirror the hardening applied to the sibling actions: move every caller-controlled
input referenced inside a run: body (package-manager, install-groups, config-file,
increment, prerelease-type, draft, package-manager-version, python-version) into an
env: block and reference the quoted variable. Convert the commitizen command from an
interpolated string (CZ_CMD="pixi run -e <install-groups> cz", executed via $CZ_CMD)
into an argv array ("${CZ_CMD[@]}"), and drop the dead CZ_CMD build in the changelog-
validation step. This removes the two real injection sinks (unquoted install-groups in
an executed command string, and config-file inside dirname "$(...)") plus the free-form
increment/prerelease-type flags. No behavior change for valid inputs; the generated
commands are identical.
Closes #29
…bodies
Round-2 review found a second-order injection the SEC-8 fix left open: the
config-file input is sanitized at the bump step, but the derived WORK_DIR is
written to a step output and then re-interpolated as
WORK_DIR="${{ steps.bump_version.outputs.WORK_DIR }}" in a later run body, where a
config-file like "$(cmd)/pyproject.toml" would surface as WORK_DIR="$(cmd)" and
execute. Route every steps.bump_version.outputs.* (plus github.server_url /
github.repository) through env: and reference the quoted variable. No run body in
this action interpolates ${{ }} anymore.
The install-groups input previously defaulted to 'groups: docs dev' (pip/uv format). For the pixi path that value is meaningless as an environment name, so a pixi caller who relied on the default silently got an invalid environment. Make the input required with no default so every caller must supply the correct value for its package manager (pip/uv: a groups:/extras: string; pixi: a single environment name). Update the five test jobs that relied on the old default (prerelease, python-versions, custom-branch, cross-platform, git-configuration) to pass install-groups: 'groups: docs dev' explicitly, preserving their prior behavior (commitizen lives in the dev group). BREAKING CHANGE: install-groups is now required; callers that omitted it and relied on the 'groups: docs dev' default must pass it explicitly.
…to security-hardening # Conflicts: # actions/mkdocs-deploy/action.yml
… unavailable install-groups is required with no default, but required is advisory for composite actions (not runtime-enforced), so a caller that omits it silently gets '' and the release fails deep in the bump step with a raw 'cz: not found' (or 'pixi run -e ""'). Add a preflight in the bump step that checks commitizen is available and, if not, emits a clear ::error:: pointing at install-groups. Checks availability (not emptiness), so the valid core-only case (empty install-groups when commitizen is a core dependency) still works.
… inputs tables install-groups is now required with no default (breaking). Update the hand-written README inputs table and the generated reference doc's install-groups row + usage snippet to match. Left the pre-existing description drift in the other reference docs alone (unrelated to this change; regenerated on the next docs deploy).
…urface its error Move the preflight to after 'cd "$WORK_DIR"' so it checks commitizen in the same directory every real cz command runs from — a monorepo config-file in a sub-package would otherwise be checked at the repo root and could false-positive for pixi. Also stop suppressing stderr (>/dev/null only) so a uv/pixi sync or network failure is not misreported as 'commitizen missing', and soften the comment: for uv the preflight rarely fires (uv run re-syncs default groups); it mainly guards pip and non-default groups.
install-groups is now required with no default, but the examples (all uv-based) omitted it — copying them would fail the new commitizen preflight for pip/pixi and silently rely on uv's auto-sync otherwise. Add install-groups: 'groups: dev' (the group that provides commitizen) to all 27 example invocations in EXAMPLES.md and getting-started.md. The version-pin snippet (no with: block) is left as-is. Kept the @github-release/v1 refs — this ships as a minor bump, not v2.
This was referenced Jul 21, 2026
Closed
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Hardens the composite actions and test workflows against the security issues found in a review of the repository.
No inputs, outputs, or observable behavior change — every generated command is identical; the values are simply passed
through
env:(and quoted) instead of being interpolated intorun:script text.What changed:
mkdocs-deployreadgithub.event.release.tag_nameand the mike alias directly inside arun:body. A release published with a tag name containing shell metacharacters ($(…), backticks,;) would run ascode on the runner with access to the deploy token. Both are now passed via
env:and quoted.softprops/action-gh-release(which receives the release token) was pinned to the mutable@v3tag; now pinned to commit3d0d9888cb7fd7b750713d6e236d1fcb99157228(v3.0.2), matching the SHA pinning alreadyused for
setup-uvandsetup-pixi.evalcalls inpython-setup/pip(argv arrays instead); routed allcaller-controlled inputs (
install-groups,package,activate-environment,pypi-repository-url,cache,skip-publish,increment,prerelease-type,config-file,draft, …) andgithub.actorthroughenv:acrossthe
pip,uv,pixi,release/pypi,mkdocs-deploy,release/github, anddeploy-docsdefinitions. Inrelease/githubthe commitizen command is also rebuilt as an argv array ("${CZ_CMD[@]}") rather than aninterpolated string.
assertion, dropped an unused
pages: write, and documented thecontents: read↔skip-github-releasecoupling.contents: readin thetest-mkdocs-deployandtest-release/githubworkflows (their jobs push only to the local fake remote), and pinned
astral-sh/setup-uvfrom@v7to the SHAalready used by the uv action (v8.3.2).
Issues
release/github/action.yml(folded into this PR during review).github.event.release.tag_namein the mkdocs-deploy release deploy.softprops/action-gh-release.evaland env-scope caller inputs in the python-setup / release-pypi run blocks.github.actorthroughenv:in the git-config steps.astral-sh/setup-uvin test-release-pypi.config-file→WORK_DIRstep output re-interpolation.Type of change
Check relevant points.
Breaking change:
release/github'sinstall-groupsinput is nowrequiredwith no default (wasdefault: 'groups: docs dev'). Callers that omitted it and relied on that default must now pass it explicitly.This warrants a major version bump when
release/githubis next tagged.How Has This Been Tested?
All 10 edited YAML files parse cleanly (
yaml.safe_load).Every non-templated
run:block passesbash -n(native git-bash).Simulated the rewritten pip/pypi argv construction to confirm the commands are unchanged:
pip install .[aws,viz],pip install . --group dev --group docs,twine upload --repository-url <url> dist/*, andtwine upload dist/*when no repository URL is set.End-to-end validation runs via the existing
test-*.ymlworkflows triggered by this PR (local execution is limitedsince these are GitHub Actions, per the repo's testing notes).
YAML validity +
bash -non all rewritten run blockspip/pypi argv-building simulation (behavior unchanged)
Checklist:
mkdocs-deploy/vX.Y.Z,release/github/vX.Y.Z,pip/vX.Y.Z) after merge, not a repo-level version.test-*.ymlsuites exerciseevery affected action; changes are behavior-preserving hardening, so no new fixtures were added.
bash -n+ argv simulation(full CI runs on this PR).