From ffa70e0bf2b6450646ccd8957e22445c19415b60 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 8 Sep 2026 07:41:43 +0000 Subject: [PATCH] Harden CI: bind auto-deploy-cloud's shell values to env, not into the script The cloud auto-deploy job expanded `${{ inputs.version }}` and the step output derived from it directly inside `run:` blocks. A `${{ }}` expansion is substituted before the shell starts, so the value arrives as program text rather than as data. Every later step in the job reads that same value, so it reached `sed -i`, a `git commit -m`, and a `gh pr create` title and body. The version also reached a `python3 -c` snippet as `'$V'.split('.')` -- pasted into the source, where a quote in the value would have ended the literal and left the remainder to run as Python. Both are now bound through `env:` and read as ordinary shell variables, and the Python comparison reads `os.environ` instead of interpolated source. Same fix pattern as #5295, #5298 and #5363; this workflow was the one left holding it. No behaviour change: the comparison returns the same verdict on the same inputs (0.12.834 > 0.12.833 yes, 0.12.833 > 0.12.834 no, 0.12.75 > 0.12.755 no), a non-numeric version is still rejected by `int()` as before, and the `permissions:` block is untouched -- it stays `contents: read`, since every cross-repo step authenticates as CLOUD_REPO_PAT. Verified: all 36 workflows parse; zero `${{ }}` expansions remain in any `run:` block in this file; tests/test_release_dispatches_cloud_deploy.py and tests/test_ci_workflow_invocations_are_real.py pass (15 tests); scripts/check_action_refs.py and scripts/check_product_record.py pass. No-PRD: CI-only hardening under .github/, an exempt path. No product behaviour changes -- the job's inputs, outputs, triggers and permissions are identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J6pLSRDuyy4zvQoGPFNxwc --- .github/workflows/auto-deploy-cloud.yml | 47 ++++++++++++++++++------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/.github/workflows/auto-deploy-cloud.yml b/.github/workflows/auto-deploy-cloud.yml index b4d3dbfcd0..1834f90035 100644 --- a/.github/workflows/auto-deploy-cloud.yml +++ b/.github/workflows/auto-deploy-cloud.yml @@ -64,6 +64,14 @@ jobs: - name: Get OSS version id: oss_version + # `inputs.version` is bound here rather than expanded inside the script. + # A `${{ }}` expansion is pasted into the shell before it runs, so the + # value becomes part of the program text; an env var is data the shell + # only ever reads. This is the taint root for the whole job -- every + # later step reads `steps.oss_version.outputs.version`, which is this + # value -- so binding it here is what makes the bindings below hold. + env: + INPUT_VERSION: ${{ inputs.version }} # Source of truth is the LATEST PUBLISHED PyPI version, not the # checkout's __version__: release-on-merge publishes the bump to # PyPI without committing it back to dashboard.py, so the file is @@ -83,7 +91,7 @@ jobs: # the cloud stayed a release behind, which is the exact symptom the # dispatch was added to cure. The publisher knows its own version; # asking a lagging index to re-derive it is what created the race. - VERSION="${{ inputs.version }}" + VERSION="$INPUT_VERSION" if [ -n "$VERSION" ]; then echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Pinning the version passed by the publisher: $VERSION" @@ -105,8 +113,10 @@ jobs: # for it to be installable before opening/merging the pin PR (avoids a # spurious cloud-CI failure that would (correctly) block the auto-merge). - name: Wait for version on PyPI + env: + OSS_VERSION: ${{ steps.oss_version.outputs.version }} run: | - V="${{ steps.oss_version.outputs.version }}" + V="$OSS_VERSION" # Poll what PIP reads, not the JSON API. pypi.org/pypi//json goes # live minutes BEFORE the simple index that pip resolves against, so # this wait used to pass while `pip install clawmetry==$V` still @@ -152,12 +162,20 @@ jobs: - name: Skip unless strictly newer than cloud's current pin env: GH_TOKEN: ${{ secrets.CLOUD_REPO_PAT }} + OSS_VERSION: ${{ steps.oss_version.outputs.version }} run: | - V="${{ steps.oss_version.outputs.version }}" + V="$OSS_VERSION" CURRENT=$(grep -oE 'clawmetry==[0-9.]+' clawmetry-cloud/Dockerfile | head -1 | cut -d= -f3) echo "target=$V cloud-main-pin=$CURRENT" - newer=$(python3 -c " - v=tuple(map(int,'$V'.split('.'))); c=tuple(map(int,'$CURRENT'.split('.'))) + # Both versions reach Python through the environment, not by being + # pasted into the source text. `'$V'.split(...)` made the value part + # of the program: a version string carrying a quote ended the literal + # and the rest of it ran as Python. os.environ is a lookup, so the + # same string is only ever data. Comparison semantics are unchanged. + newer=$(V="$V" CURRENT="$CURRENT" python3 -c " + import os + v=tuple(map(int,os.environ['V'].split('.'))) + c=tuple(map(int,os.environ['CURRENT'].split('.'))) print('yes' if v>c else 'no')") if [ "$newer" != "yes" ]; then echo "Target $V is not newer than the current pin $CURRENT — refusing to pin downwards." @@ -172,11 +190,13 @@ jobs: - name: Pin clawmetry version in cloud Dockerfile if: env.SKIP != '1' + env: + OSS_VERSION: ${{ steps.oss_version.outputs.version }} run: | cd clawmetry-cloud - sed -i "s/clawmetry==[0-9.]*/clawmetry==${{ steps.oss_version.outputs.version }}/" Dockerfile - grep -q "clawmetry==${{ steps.oss_version.outputs.version }}" Dockerfile || \ - sed -i "s/clawmetry$/clawmetry==${{ steps.oss_version.outputs.version }}/" Dockerfile + sed -i "s/clawmetry==[0-9.]*/clawmetry==$OSS_VERSION/" Dockerfile + grep -q "clawmetry==$OSS_VERSION" Dockerfile || \ + sed -i "s/clawmetry$/clawmetry==$OSS_VERSION/" Dockerfile echo "Dockerfile:"; grep clawmetry Dockerfile - name: Commit and open PR @@ -185,11 +205,11 @@ jobs: cd clawmetry-cloud git config user.email "diya-bot@clawmetry.com" git config user.name "Diya (auto-deploy)" - BRANCH="auto/pin-clawmetry-${{ steps.oss_version.outputs.version }}" + BRANCH="auto/pin-clawmetry-$OSS_VERSION" git checkout -b "$BRANCH" git add Dockerfile git diff --staged --quiet && echo "No change needed" && exit 0 - git commit -m "chore: auto-pin clawmetry==${{ steps.oss_version.outputs.version }} from OSS" + git commit -m "chore: auto-pin clawmetry==$OSS_VERSION from OSS" # Force-push: this automation-owned branch may already exist from a # prior run for the same version (re-fired release, retried merge). # A plain push is rejected ("fetch first") and the whole job goes red, @@ -203,14 +223,15 @@ jobs: else gh pr create \ --repo vivekchand/clawmetry-cloud \ - --title "chore: auto-pin clawmetry==${{ steps.oss_version.outputs.version }}" \ - --body "Automated version pin from OSS release ${{ steps.oss_version.outputs.version }}. Merge to deploy." \ + --title "chore: auto-pin clawmetry==$OSS_VERSION" \ + --body "Automated version pin from OSS release $OSS_VERSION. Merge to deploy." \ --base main \ --head "$BRANCH" - echo "✅ PR opened to pin clawmetry==${{ steps.oss_version.outputs.version }}" + echo "✅ PR opened to pin clawmetry==$OSS_VERSION" fi env: GH_TOKEN: ${{ secrets.CLOUD_REPO_PAT }} + OSS_VERSION: ${{ steps.oss_version.outputs.version }} # Auto-merge the pin PR once the cloud CI is green so each OSS release # deploys to the cloud hands-off. We poll the PR's checks and merge with