From b053807317cb51cca2ba8d0288faf0d7f8e8b885 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 15:03:12 +0100 Subject: [PATCH 1/3] ci(release): pass dispatch inputs via env and scope write permissions to publishing jobs Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .github/workflows/release.yml | 17 ++++++++++----- CHANGELOG.md | 6 ++++++ scripts/tests/test_scripts_release_safety.py | 22 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2b9258df..d89399b83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,9 @@ concurrency: group: release cancel-in-progress: false +# Read-only by default; only the jobs that publish or push widen it. permissions: - contents: write - id-token: write # OIDC for PyPI and npm Trusted Publishing + contents: read env: NODE_VERSION: "24" @@ -47,11 +47,16 @@ jobs: run: bash scripts/check_release_ref.sh - id: compute shell: bash + # Inputs reach the script through the environment, never interpolated + # into it, so a crafted value is data rather than shell source. + env: + INPUT_VERSION: ${{ inputs.version }} + BUMP: ${{ inputs.bump }} run: | - if [ -n "${{ inputs.version }}" ]; then - ver="${{ inputs.version }}" + if [ -n "${INPUT_VERSION}" ]; then + ver="${INPUT_VERSION}" else - ver=$(BUMP="${{ inputs.bump }}" python3 <<'PY' + ver=$(python3 <<'PY' import os, pathlib, re, tomllib cur = tomllib.loads(pathlib.Path("framework/core/pyproject.toml").read_text())["project"]["version"] m = re.match(r"^(\d+)\.(\d+)\.(\d+)", cur) @@ -278,6 +283,8 @@ jobs: needs: [resolve, publish-pypi, publish-npm] runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: write # push the release commit + tag, create the GitHub Release steps: - uses: actions/checkout@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6c16b03..b77238115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Security +- The release workflow passes dispatch inputs to its scripts through the + environment instead of templating them into shell source, and runs + read-only except for the publish jobs (OIDC) and `finalize` (push + GitHub + Release). + ## [0.0.43] — 2026-11-04 ### Added diff --git a/scripts/tests/test_scripts_release_safety.py b/scripts/tests/test_scripts_release_safety.py index 6111e0d25..ab5fe8c68 100644 --- a/scripts/tests/test_scripts_release_safety.py +++ b/scripts/tests/test_scripts_release_safety.py @@ -127,3 +127,25 @@ def test_finalize_preserves_main_and_only_tags_on_success(release_repos, advance assert git(remote, "merge-base", "--is-ancestor", main_before, "main") == "" if advance == "unrelated": assert git(remote, "show", "main:merged") == "PR change" + + +def test_dispatch_inputs_never_interpolated_into_scripts(): + """Inputs reach `run:` scripts through `env:` so their values stay data.""" + for name, job in workflow()["jobs"].items(): + for step in job.get("steps", []): + assert "inputs." not in step.get("run", ""), f"{name}: {step.get('name')}" + + +def test_only_publishing_jobs_widen_permissions(): + wf = workflow() + assert wf["permissions"] == {"contents": "read"} + writers = { + name: job["permissions"] + for name, job in wf["jobs"].items() + if any(v == "write" for v in job.get("permissions", {}).values()) + } + assert writers == { + "publish-pypi": {"id-token": "write"}, + "publish-npm": {"contents": "read", "id-token": "write"}, + "finalize": {"contents": "write"}, + } From a9b9a94898c74716938c4933594349fcde19130c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 15:24:29 +0100 Subject: [PATCH 2/3] ci(release): composite bump action reads the version from env too Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .github/actions/bump-version/action.yml | 8 ++++++-- scripts/tests/test_scripts_release_safety.py | 15 +++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/actions/bump-version/action.yml b/.github/actions/bump-version/action.yml index b11490861..a41ce76fa 100644 --- a/.github/actions/bump-version/action.yml +++ b/.github/actions/bump-version/action.yml @@ -16,7 +16,9 @@ runs: steps: - name: Bump all package versions (working tree only) shell: bash - run: uv run python scripts/bump_version.py "${{ inputs.version }}" + env: + VERSION: ${{ inputs.version }} + run: uv run python scripts/bump_version.py "$VERSION" # Workspace members are pinned in uv.lock (version + ==pins); PR CI runs # `uv sync --locked`, so a bump that skips this breaks every PR after it. - name: Regenerate uv lockfile @@ -28,4 +30,6 @@ runs: - name: Cut CHANGELOG section if: inputs.cut-changelog == 'true' shell: bash - run: uv run python scripts/cut_changelog.py "${{ inputs.version }}" + env: + VERSION: ${{ inputs.version }} + run: uv run python scripts/cut_changelog.py "$VERSION" diff --git a/scripts/tests/test_scripts_release_safety.py b/scripts/tests/test_scripts_release_safety.py index ab5fe8c68..c034988c2 100644 --- a/scripts/tests/test_scripts_release_safety.py +++ b/scripts/tests/test_scripts_release_safety.py @@ -131,9 +131,15 @@ def test_finalize_preserves_main_and_only_tags_on_success(release_repos, advance def test_dispatch_inputs_never_interpolated_into_scripts(): """Inputs reach `run:` scripts through `env:` so their values stay data.""" - for name, job in workflow()["jobs"].items(): - for step in job.get("steps", []): - assert "inputs." not in step.get("run", ""), f"{name}: {step.get('name')}" + steps = [ + (name, step) for name, job in workflow()["jobs"].items() for step in job.get("steps", []) + ] + for action in (ROOT / ".github/actions").glob("*/action.yml"): + steps += [ + (action.parent.name, s) for s in yaml.safe_load(action.read_text())["runs"]["steps"] + ] + for name, step in steps: + assert "inputs." not in step.get("run", ""), f"{name}: {step.get('name')}" def test_only_publishing_jobs_widen_permissions(): @@ -142,7 +148,8 @@ def test_only_publishing_jobs_widen_permissions(): writers = { name: job["permissions"] for name, job in wf["jobs"].items() - if any(v == "write" for v in job.get("permissions", {}).values()) + if isinstance(job.get("permissions"), dict) + and any(v == "write" for v in job["permissions"].values()) } assert writers == { "publish-pypi": {"id-token": "write"}, From a0d339f85b1d9247a416234a82a5300c7fd7fb8b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 15:32:10 +0100 Subject: [PATCH 3/3] test(release): follow the composite action's env-based version Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- scripts/tests/test_scripts_release_changelog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test_scripts_release_changelog.py b/scripts/tests/test_scripts_release_changelog.py index 0043e91e4..fdfdc17ac 100644 --- a/scripts/tests/test_scripts_release_changelog.py +++ b/scripts/tests/test_scripts_release_changelog.py @@ -24,7 +24,8 @@ def test_finalize_cuts_changelog_before_commit_and_extracts_dated_notes() -> Non action = (REPO_ROOT / ".github/actions/bump-version/action.yml").read_text(encoding="utf-8") cut_step = action.split("- name: Cut CHANGELOG section", 1)[1] assert "if: inputs.cut-changelog == 'true'" in cut_step - assert 'run: uv run python scripts/cut_changelog.py "${{ inputs.version }}"' in cut_step + assert "VERSION: ${{ inputs.version }}" in cut_step + assert 'run: uv run python scripts/cut_changelog.py "$VERSION"' in cut_step assert "--notes" not in cut_step