The problem
.github/scripts/wait-for-crate.sh builds a Python program by pasting a git tag
name into its source text:
for attempt in $(seq 1 "$max_attempts"); do
if curl -fsSL "$url" 2>/dev/null | python3 -c "
import json, sys
target = '$version'
for line in sys.stdin:
…
"; then
$version is unquoted, inside a single-quoted Python string literal, inside a
double-quoted shell string. The shell expands it before Python ever sees it.
.github/workflows/release.yml calls it with a tag:
run: ./.github/scripts/wait-for-crate.sh contextgraph-types "${GITHUB_REF_NAME#contextgraph-v}"
so $version is whatever follows contextgraph-v in a pushed tag name. Git tag
names accept apostrophes, newlines, and almost every other character.
Two outcomes, depending on the tag:
- A tag like
contextgraph-v1.0'x closes the Python literal and the script dies
with a SyntaxError — a confusing release failure with no hint of the cause.
- A tag crafted with a newline and a statement runs arbitrary Python in the
job that holds CARGO_REGISTRY_TOKEN, which is exported into the same job's
environment for the four cargo publish steps.
The same script is also called for contextgraph-host, so the pattern repeats.
Pushing a tag needs write access to the repository, so this is not
attacker-from-the-internet. It is still the wrong shape in the one job that
holds a publishing credential, and it is a two-line fix.
The fix shape
Pass the value through the environment instead of the source text:
if VERSION="$version" curl -fsSL "$url" 2>/dev/null | python3 -c "
import json, os, sys
target = os.environ['VERSION']
…
"; then
The script already uses $crate and $version safely everywhere else — they
are quoted shell expansions in printf, [[ ]] and echo. This one site is
the only place either value crosses into another language.
A second, unrelated defect in the same release path
release.yml's preflight job runs:
- run: cargo publish --dry-run -p contextgraph-types
and the publish job runs:
run: cargo publish -p contextgraph-types --locked
The preflight is allowed to update Cargo.lock; the real publish is not. So a
lockfile out of sync with Cargo.toml gives a green preflight and then fails at
the first step of the publish job — after a human has spent the approval click
the workflow header describes. Adding --locked to the dry run makes the
preflight test what the publish will actually do.
What I verified vs. inferred
Verified by reading, on origin/main at a01ca64:
.github/scripts/wait-for-crate.sh's python3 -c "…target = '$version'…".
release.yml's two call sites passing "${GITHUB_REF_NAME#contextgraph-v}".
CARGO_REGISTRY_TOKEN being set on the four cargo publish steps in the same
job as the two wait-for-crate.sh steps.
- The
--dry-run / --locked asymmetry between the two jobs.
Inferred, not verified: the exact tag string that would achieve execution
rather than a syntax error. I did not construct one — the quoting is
unambiguous, and building a working exploit is not needed to justify the fix.
What "done" looks like
wait-for-crate.sh passes the crate name and version to Python through
os.environ, or through jq --arg, rather than through string
interpolation. Nothing in the script's behaviour changes for a well-formed
tag.
- Witness: run
./.github/scripts/wait-for-crate.sh contextgraph-types "1.0'x" 1 0
and confirm it fails cleanly with "not visible on the sparse index" rather
than with a Python SyntaxError.
release.yml's preflight dry run uses --locked, matching the publish.
Constraints
The problem
.github/scripts/wait-for-crate.shbuilds a Python program by pasting a git tagname into its source text:
$versionis unquoted, inside a single-quoted Python string literal, inside adouble-quoted shell string. The shell expands it before Python ever sees it.
.github/workflows/release.ymlcalls it with a tag:so
$versionis whatever followscontextgraph-vin a pushed tag name. Git tagnames accept apostrophes, newlines, and almost every other character.
Two outcomes, depending on the tag:
contextgraph-v1.0'xcloses the Python literal and the script dieswith a
SyntaxError— a confusing release failure with no hint of the cause.job that holds
CARGO_REGISTRY_TOKEN, which is exported into the same job'senvironment for the four
cargo publishsteps.The same script is also called for
contextgraph-host, so the pattern repeats.Pushing a tag needs write access to the repository, so this is not
attacker-from-the-internet. It is still the wrong shape in the one job that
holds a publishing credential, and it is a two-line fix.
The fix shape
Pass the value through the environment instead of the source text:
The script already uses
$crateand$versionsafely everywhere else — theyare quoted shell expansions in
printf,[[ ]]andecho. This one site isthe only place either value crosses into another language.
A second, unrelated defect in the same release path
release.yml'spreflightjob runs:and the
publishjob runs:The preflight is allowed to update
Cargo.lock; the real publish is not. So alockfile out of sync with
Cargo.tomlgives a green preflight and then fails atthe first step of the publish job — after a human has spent the approval click
the workflow header describes. Adding
--lockedto the dry run makes thepreflight test what the publish will actually do.
What I verified vs. inferred
Verified by reading, on
origin/mainat a01ca64:.github/scripts/wait-for-crate.sh'spython3 -c "…target = '$version'…".release.yml's two call sites passing"${GITHUB_REF_NAME#contextgraph-v}".CARGO_REGISTRY_TOKENbeing set on the fourcargo publishsteps in the samejob as the two
wait-for-crate.shsteps.--dry-run/--lockedasymmetry between the two jobs.Inferred, not verified: the exact tag string that would achieve execution
rather than a syntax error. I did not construct one — the quoting is
unambiguous, and building a working exploit is not needed to justify the fix.
What "done" looks like
wait-for-crate.shpasses the crate name and version to Python throughos.environ, or throughjq --arg, rather than through stringinterpolation. Nothing in the script's behaviour changes for a well-formed
tag.
./.github/scripts/wait-for-crate.sh contextgraph-types "1.0'x" 1 0and confirm it fails cleanly with "not visible on the sparse index" rather
than with a Python
SyntaxError.release.yml's preflight dry run uses--locked, matching the publish.Constraints
release.yml; keep the signature. Its usage line and the argument order aredocumented in
PUBLISHING.md.release.ymlhas never actually run a publish (CARGO_REGISTRY_TOKENis notset — see Publish the contextgraph 2.0.0 crates to crates.io (crates-io environment + token are not set up) #102), so there is no history to preserve and no risk of changing
behaviour anyone depends on. That also means this is worth fixing before the
first real release rather than after.
happened and the tag conventions disagreeing. This issue is only about the
script's quoting and the preflight flag mismatch; it does not depend on those.