Skip to content

fix(ci): reject unsafe release dispatch tags - #249

Open
MikeTomlin19 wants to merge 11 commits into
Gitlawb:mainfrom
MikeTomlin19:agent/harden-release-tag-239
Open

fix(ci): reject unsafe release dispatch tags#249
MikeTomlin19 wants to merge 11 commits into
Gitlawb:mainfrom
MikeTomlin19:agent/harden-release-tag-239

Conversation

@MikeTomlin19

@MikeTomlin19 MikeTomlin19 commented Jul 25, 2026

Copy link
Copy Markdown

Summary

Validate manual release tags before writing them to $GITHUB_OUTPUT, preventing malformed multiline input from creating additional step outputs.

Motivation & context

The existing v[0-9]* shell pattern is a prefix check, not a complete validation rule, and echo "tag=$TAG" writes embedded newlines verbatim. A malformed workflow_dispatch tag can therefore add unintended output keys consumed by later release steps.

This is input hygiene rather than a privilege boundary: manual dispatch inputs are limited to write-access collaborators, who also control the selected workflow ref. The separate workflow-trust boundary is tracked in #234 and remains out of scope.

Closes #239

Kind of change

  • Bug fix
  • Feature
  • Security fix
  • Docs
  • Tests / CI
  • Refactor (no behavior change)
  • Breaking or protocol change (issue required first)

What changed

  • Moved tag resolution into one shared, allowlist-validating helper used by all three release jobs.
  • Added three initial actions/checkout steps so each job can run the shared helper; docker-manifest now explicitly requests contents: read.
  • Rejects empty, multiline, whitespace-containing, path-containing, shell-metacharacter, Docker-incompatible +build, and bad-shape inputs before any output is written.
  • Preserves the existing v[0-9]* release-tag shape check.
  • Uses printf for validated tag and version outputs while preserving ::error:: annotations on stdout.
  • Added executable valid/newline/empty/invalid-shape regression cases, including explicit +build rejection.
  • Added a PR-check job that runs the resolver suite.
  • Structurally pins the complete Check out workflow scripts and id: rel step blocks in exactly three jobs: docker, docker-manifest, and npm-publish. Any change to those reviewed blocks must be reflected deliberately in the test fixture.

The structural check is intentionally narrow: it verifies those three exact checkout-and-resolver block pairs and does not claim to detect arbitrary output writes elsewhere in the workflow.

How a reviewer can verify

scripts/test-resolve-release-tag.sh

go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 \
  -ignore 'label "macos-15-intel" is unknown' \
  .github/workflows/release.yml .github/workflows/pr-checks.yml

The regression script verifies exact outputs for v1.2.3; verifies that empty, multiline, whitespace-containing, shell-metacharacter, path-containing, build-metadata, uppercase-prefix, and bad-shape inputs fail without writing output; and compares the complete three checkout-and-resolver block pairs against their reviewed expected content.

Before you request review

  • Scope is one logical change; no unrelated churn
  • cargo test --workspace passes locally (Rust sources are unchanged)
  • New behavior is covered by validation cases
  • cargo fmt --all and cargo clippy --workspace --all-targets -- -D warnings are clean (Rust sources are unchanged)
  • Commit titles use Conventional Commits (feat(...), fix(...), docs(...))
  • Docs / .env.example updated if behavior or config changed (N/A)
  • Checked existing PRs so this isn't a duplicate

Protocol & signing impact

N/A — this does not change identity, signatures, UCANs, ref certificates, or P2P wire formats.

Notes for reviewers

The release tag validation job runs on pull_request and merge_group.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a shared Bash script for validating and resolving release tags, updates Docker and npm release jobs to use it, and adds PR checks for valid and invalid inputs plus workflow wiring.

Changes

Release tag validation

Layer / File(s) Summary
Resolver validation and tests
scripts/resolve-release-tag.sh, scripts/test-resolve-release-tag.sh
The resolver validates allowed tag characters and v-prefixed format, writes tag and version outputs, and tests valid and invalid inputs plus workflow guard behavior.
Publishing workflow integration
.github/workflows/release.yml
Docker, Docker manifest, and npm publishing jobs check out the script and use it for release-tag resolution.
PR validation job
.github/workflows/pr-checks.yml
Adds a five-minute job that runs the release-tag resolver test harness.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • Gitlawb/node#233: Updates release workflow tag and version handling for the npm publishing path.
  • Gitlawb/node#235: Reworks Docker publishing and backfill tagging flow in the release workflow.

Suggested labels: kind:ci

Suggested reviewers: beardthelion

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #239 by validating full tag inputs before output writing and preserving the existing release-tag shape check.
Out of Scope Changes check ✅ Passed The added helper, tests, and PR-check job all support the release-tag validation fix and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is concise and accurately summarizes the main change: rejecting unsafe release dispatch tags in CI.
Description check ✅ Passed The description follows the required template and includes summary, context, change details, verification steps, and reviewer notes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the workflow-change Fork PR edits CI workflows — mandatory human review label Jul 25, 2026
@MikeTomlin19
MikeTomlin19 marked this pull request as ready for review July 25, 2026 19:30

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found an issue that needs to be addressed before this is ready.

Findings

  • [P3] Add the claimed regression coverage, or correct the PR description
    .github/workflows/release.yml:86
    The PR says that the new validation cases are covered, but this change adds no executable check for any of the three Resolve release tag blocks; Actionlint only validates workflow syntax and does not execute the shell. A later edit can therefore restore the newline $GITHUB_OUTPUT injection while CI remains green. Add a focused probe that exercises a valid tag and a newline-containing tag for each resolver (or remove the coverage claim).

@MikeTomlin19

Copy link
Copy Markdown
Author

Addressed the requested executable coverage in 64e228f.

The three release jobs now call one shared scripts/resolve-release-tag.sh implementation. scripts/test-resolve-release-tag.sh executes that resolver with a valid tag, a newline-injection value, and an empty value; it verifies exact valid outputs, verifies invalid cases write nothing, and asserts that all three workflow steps use the tested helper.

The regression script is now a blocking release tag validation job in pr-checks.yml.

Validation:

  • scripts/test-resolve-release-tag.sh — passed
  • bash -n scripts/resolve-release-tag.sh scripts/test-resolve-release-tag.sh — passed
  • Actionlint 1.7.7 — passed with only the repository's pre-existing macos-15-intel runner-label diagnostic ignored

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/test-resolve-release-tag.sh`:
- Around line 16-28: Add rejected test cases in
scripts/test-resolve-release-tag.sh for release tags containing whitespace,
shell metacharacters, and values that do not match the v[0-9]* format. Follow
the existing newline and empty-tag assertions: invoke $resolver with each
invalid value, require failure, and verify the corresponding GITHUB_OUTPUT file
remains empty.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ae66396-c00b-4045-a864-498dc8c16b79

📥 Commits

Reviewing files that changed from the base of the PR and between 111cff7 and 64e228f.

📒 Files selected for processing (4)
  • .github/workflows/pr-checks.yml
  • .github/workflows/release.yml
  • scripts/resolve-release-tag.sh
  • scripts/test-resolve-release-tag.sh

Comment thread scripts/test-resolve-release-tag.sh

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beardthelion LGTM but needs your evaluation

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolver itself holds up. I reproduced the original injection against the pre-PR logic (v1\nversion=9.9.9\ntag=ghcr.io/attacker/evil passes the old glob, and last-wins resolution yields tag=ghcr.io/attacker/evil), confirmed the new script rejects that exact payload with a zero-byte $GITHUB_OUTPUT, and mutated every protection in it: the character allowlist, the shape check, and removing a call site each turn the suite red. actionlint 1.7.7 runs clean on both workflows.

What needs another round is the drift guard, not the fix.

Findings

  • [P2] Assert the invariant, not a line count

scripts/test-resolve-release-tag.sh:40

grep -c 'scripts/resolve-release-tag.sh' ... -ne 3 counts matching lines rather than resolver invocations, so it fails in both directions. I executed three reintroductions of the original bug that all leave the suite green: reverting one call site to inline while a comment still names the script, adding a fourth job with the old inline write, and keeping all three calls but wrapping one in || { echo "tag=$TAG" >> "$GITHUB_OUTPUT"; } so the rejection is swallowed. Meanwhile a benign comment naming the script flips it red with all three call sites intact.

Replacement, run against the current head plus all three bypasses plus the comment case, 5/5 as intended:

workflows="$repo_root/.github/workflows"
steps="$(grep -c 'name: Resolve release tag' "$workflows/release.yml" || true)"
calls="$(grep -cE '^[[:space:]]*scripts/resolve-release-tag\.sh ' "$workflows/release.yml" || true)"

if [[ "$steps" -lt 1 || "$steps" -ne "$calls" ]]; then
  printf '%s\n' "every 'Resolve release tag' step must call the tested resolver; steps=$steps calls=$calls" >&2
  exit 1
fi

if grep -rqE '(echo|printf)[^|]*(tag|version)=.*>>[[:space:]]*"?\$GITHUB_OUTPUT' "$workflows"; then
  printf '%s\n' "a workflow writes a tag/version output without the tested resolver:" >&2
  grep -rnE '(echo|printf)[^|]*(tag|version)=.*>>[[:space:]]*"?\$GITHUB_OUTPUT' "$workflows" >&2
  exit 1
fi

The || true earns its place separately: with all three call sites gone the count is 0, grep -c exits 1, and set -e kills the assignment before the intended "found 0" message ever prints. The second grep is clean on the current tree, where the only other $GITHUB_OUTPUT writers are image= and enabled=.

  • [P2] Cover the two resolver relaxations that survive the suite

scripts/test-resolve-release-tag.sh:31

Changing v[0-9]*) to v*) keeps the suite green and admits v-1, vlatest, and bare v. Widening the allowlist to [!A-Za-z0-9._/-] also stays green and admits v1.2.3/../../x. Extending the existing invalid loop to "v-1" "vlatest" "v" "v1.2.3/../../x" "V1.2.3" closes both: I ran all five against the current resolver (rejected, zero-byte output) and against each mutant (accepted, so every added case is load-bearing).

  • [P3] Fix three claims in the description

The body omits the three added actions/checkout steps and docker-manifest's new contents: read. It says the test asserts all three release jobs call the tested resolver, which the first finding disproves. It also overstates the new job's role: it runs on pull_request and merge_group with no path filter and passes here in 5s, and the description should not claim more than that. The wording is the only thing to change.

  • [P3] Note two narrow behavior changes

scripts/resolve-release-tag.sh:7

The allowlist rejects +, so v1.2.3+build.5 now fails where the old glob accepted it. release-please emits plain vX.Y.Z, so this only reaches a hand-dispatched backfill, and it fails loudly rather than silently. Separately, the reject-path ::error:: annotations moved from stdout to stderr. The step still exits non-zero either way, but worth confirming the annotation still renders, since I did not test that against a runner.

Scope note on framing: the tag validation here is input hygiene rather than a privilege boundary. Only a write-access collaborator can supply docker_backfill_tag or npm_backfill_tag, and a dispatch runs the workflow from the selected ref, so that same actor controls both the invoking run: block and, since the new checkouts carry no ref:, the validator script itself. That is not an argument against the change, just a reason to keep the description modest. The gap tracked in #234 is what actually constrains that surface, and it is deliberately out of scope here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/test-resolve-release-tag.sh`:
- Around line 62-78: Update the workflow validation in the test script to
inspect each individual “Resolve release tag” step rather than comparing
aggregate counts. Extract each matching step’s run block, require that block to
invoke scripts/resolve-release-tag.sh, and reject tag= or version= writes to
GITHUB_OUTPUT even when the echo/printf command spans multiple lines; retain the
existing failure diagnostics and nonzero exit behavior.
- Around line 16-21: Update the resolver exercised by the test to prevent
Docker-incompatible versions containing “+” build metadata from being published:
either reject such tags through the existing allowlist validation or normalize
the metadata before producing the release version used for Docker tagging.
Adjust the test’s expected behavior so v1.2.3+build.5 is not accepted as a valid
release version, while preserving valid semver tag handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 580b5996-6788-468f-bc4a-be29195dee6d

📥 Commits

Reviewing files that changed from the base of the PR and between 64e228f and c00eaa2.

📒 Files selected for processing (2)
  • scripts/resolve-release-tag.sh
  • scripts/test-resolve-release-tag.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/resolve-release-tag.sh

Comment thread scripts/test-resolve-release-tag.sh Outdated
Comment thread scripts/test-resolve-release-tag.sh Outdated

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolver is right and I could not get anything past it: the original injection payload, shell metacharacters, whitespace, path traversal and case variants all reject with a zero-byte $GITHUB_OUTPUT, and the five invalid cases from last round are in and load-bearing. Two things need another pass, and the first one is a hole in the guard I handed you.

Findings

  • [P2] Bind the drift guard to each resolver step, not to file-wide counts
    scripts/test-resolve-release-tag.sh:62
    The guard I proposed compares a count of name: Resolve release tag lines against a count of resolver-call lines, and both greps are line-scoped. I rewrote the docker-manifest step to write tag/version through a { echo ...; echo ...; } >> "$GITHUB_OUTPUT" brace group and moved a decoy resolver call into a new step so the counts stayed 3 and 3. The suite exits 0 and prints "release tag resolver tests passed" while the original injection is fully back: that step resolves tag=ghcr.io/attacker/evil, version=9.9.9. The brace form is the idiom release.yml already uses elsewhere, so this is the shape drift takes by default, not a contrived edit. What worked when I ran it: extract each Resolve release tag step's own run block, flatten it to one line, require the resolver call in that block, and reject (tag|version)= followed by a $GITHUB_OUTPUT or $GITHUB_ENV redirect anywhere in it. Exit 0 on the current tree with steps=3, exit 1 on the bypass. Keep it per-step and key-scoped rather than a blanket ban, because echo "image=..." at release.yml:97 legitimately lives inside the first resolver step; my first attempt banned every redirect and false-positived on exactly that line. CodeRabbit raised the same class at line 78 and that thread is still unresolved.

  • [P2] Reject + again and assert the build-metadata tag fails
    scripts/resolve-release-tag.sh:8
    + entered the allowlist in c00eaa2; 64e228f did not have it. That came from my own P3 last round, which flagged the + rejection as a behavior change worth noting, and I should have been clearer that noting it was the whole ask. Accepting it is worse: version flows into docker buildx imagetools create -t "$IMAGE:$VERSION" at release.yml:222, and 1.2.3+build.5 is not a legal image tag, nor is the 1.2.3+build that MAJOR_MINOR="${VERSION%.*}" derives at :216. The suite at line 16 currently pins that broken value as correct. I dropped + from the allowlist and turned the build-metadata block into a rejection assertion: suite green, v0.7.0 still resolves, and putting + back turns it red, so the new case carries its weight. release-please emits plain vX.Y.Z, so nothing real regresses.

  • [P3] CI had never run on this head
    .github/workflows/pr-checks.yml:51
    PR Checks was sitting at action_required, so the release tag validation job had not executed once in the environment it exists for and the green rollup was triage jobs only. I approved the pending run, so it is going now. The job's own wiring reads correctly: pull_request and merge_group, no path filter, contents: read, persist-credentials: false.

  • [P3] Correct two claims in the description
    The body says the change "preserves safe +build metadata", which the finding above asks you to reverse, and that the drift guard requires every named Resolve release tag step to call the tested helper, which the bypass above disproves.

@beardthelion
beardthelion dismissed their stale review July 27, 2026 04:16

Superseded by my review on c00eaa2; dismissing so the state reflects the current head.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
scripts/test-resolve-release-tag.sh (1)

138-164: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bypass fixture is coupled to release.yml's exact job order and step naming.

The resolver_calls == 2 check assumes the 2nd scripts/resolve-release-tag.sh occurrence is always the docker-manifest job, and the decoy insertion is anchored to a step literally named "Download digests". If jobs are reordered or that step is renamed, this fixture silently stops testing what it intends to (though check_resolver_steps would likely still fail correctly via the replaced-call detection alone). Consider anchoring on the docker-manifest: job header instead of an ordinal count for resilience against future release.yml restructuring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-resolve-release-tag.sh` around lines 138 - 164, Make the bypass
fixture target the docker-manifest job explicitly instead of relying on
resolver_calls == 2 and the literal “Download digests” step name. Update the awk
logic around the release workflow transformation to track the docker-manifest:
job and inject the malicious resolver output and decoy call within that job,
preserving the existing check_resolver_steps assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@scripts/test-resolve-release-tag.sh`:
- Around line 138-164: Make the bypass fixture target the docker-manifest job
explicitly instead of relying on resolver_calls == 2 and the literal “Download
digests” step name. Update the awk logic around the release workflow
transformation to track the docker-manifest: job and inject the malicious
resolver output and decoy call within that job, preserving the existing
check_resolver_steps assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 02222c60-cae8-4263-ac30-a43f18051351

📥 Commits

Reviewing files that changed from the base of the PR and between c00eaa2 and 1975d29.

📒 Files selected for processing (2)
  • scripts/resolve-release-tag.sh
  • scripts/test-resolve-release-tag.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/resolve-release-tag.sh

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #239 fix on the current tree looks correct. I still found gaps in the regression guard and CI evidence before I would call this fully ready.

What looks good

  • scripts/resolve-release-tag.sh rejects the original multiline workflow_dispatch payload (v1.2.3\nname=owned): non-zero exit, zero-byte $GITHUB_OUTPUT.
  • All three release jobs on head call the real script, not a comment or decoy.
  • beardthelion's earlier brace-group and +build follow-ups appear addressed on 73a6a0a.

Findings

  • [P2] Require a real resolver invocation, not a commented reference
    scripts/test-resolve-release-tag.sh:62
    This does not reopen #239 on the current diff, but it does leave the drift guard weaker than the PR description implies. resolver_call_re treats # scripts/resolve-release-tag.sh … as a call, so a step can comment out the script, keep the old inline path (or echo "$VAR" >> "$GITHUB_OUTPUT" with a multiline VAR), and scripts/test-resolve-release-tag.sh still exits 0. Please match only uncommented invocations.

  • [P2] Extend the per-step output guard beyond echo/printf
    scripts/test-resolve-release-tag.sh:63
    Same scope note: not a live bug in today's release.yml, but a real guard hole if someone regresses later. The brace-group bypass is fixed, yet a step that calls the resolver and then appends tag/version via tee -a "$GITHUB_OUTPUT" or a cat >> "$GITHUB_OUTPUT" heredoc still passes check_resolver_steps. Please reject any same-step tag=/version= write to $GITHUB_OUTPUT or $GITHUB_ENV, and add mutation cases for tee and heredoc appends.

  • [P2] Bind the drift guard to all three release jobs
    scripts/test-resolve-release-tag.sh:88
    Also a future-regression gap, not a current-tree failure. The guard only inspects steps literally named Resolve release tag and only fails when zero such steps exist. Renaming the docker job's resolver step and reverting that job to inline echo "tag=$TAG" >> "$GITHUB_OUTPUT" leaves two guarded steps and the suite still passes. Please assert all three call sites stay guarded, and add a rename/revert regression.

  • [P3] release tag validation has not reported on the current head
    .github/workflows/pr-checks.yml:51
    GitHub's status rollup for head 73a6a0ac6da814a09a1efea293c457c5d9a1c62f only shows Quality-signal triage and CodeRabbit; none of the PR Checks jobs completed on this SHA. This may be fork workflow-approval noise, but please get a green release tag validation run on the latest head before merge so the new suite is actually exercised in CI.

Merge posture

If the bar is only "fix #239 in today's workflow," the resolver half is there. If the bar is "make the new regression suite as load-bearing as described," the three guard items above still need another pass.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #239 fix on the current tree looks correct. I do not see a live reopening of the injection bug. What remains is drift-guard coverage and CI evidence.

Findings

  • [P3] Remaining drift-guard holes if you want the suite to match the PR description
    scripts/test-resolve-release-tag.sh:121
    This does not reopen #239 on today's release.yml. All three jobs still call the real resolver, and I could not get the original multiline payload past it. The follow-up work on 1fe33c7 also addressed the commented-call, tee, heredoc-with-tag=, brace-group, rename, and rename+revert regressions I asked for on 73a6a0a. If the bar is only "fix #239 in today's workflow," you can treat the rest as optional hardening.
    If the bar is "make the regression suite as load-bearing as the description claims," there are still guard holes. has_output_sink misses >> "${GITHUB_OUTPUT}", and has_tag_assignment only looks for literal (tag|version)=, so several same-step write shapes pass check_resolver_steps on mutated workflows while reintroducing arbitrary tag / version outputs after the resolver: || printf '%s=%s\n' tag "$TAG", || true plus a follow-up write, GitHub's tag<<EOF delimiter syntax, process.env.GITHUB_OUTPUT / environ["GITHUB_OUTPUT"] writes, and dynamic-key forms like KEY="tag"; echo "${KEY}=evil". I reproduced each locally against copied release.yml fixtures; the suite stayed green. Consider tightening the guard and adding mutation cases only if you want that broader claim to be true.

  • [P3] release tag validation still has not reported on the current head
    .github/workflows/pr-checks.yml:51
    Head 1fe33c7 only shows Quality-signal triage and CodeRabbit in GitHub's status rollup. The PR Checks run for that SHA (30362003516) finished as action_required with no completed jobs, so the new job has not executed in the environment it exists for. Local scripts/test-resolve-release-tag.sh passes on this checkout, but the latest guard-hardening commits (509f79f, 1fe33c7) have not been exercised by CI on the merge candidate SHA. Please get a green release tag validation run on the latest head before merge.

@beardthelion
beardthelion dismissed their stale review July 28, 2026 21:49

Superseded; re-reviewed on 1fe33c7.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #239 fix is done and I am satisfied with it. release tag validation is green on 1fe33c7, which closes the CI-evidence gap from the last two rounds. Gutting the character allowlist turns the suite red on the original payload, widening v[0-9]* to v* turns it red on v-1, and the resolver rejects everything I threw at it.

The drift guard is where I am calling a stop. I am settling scope here rather than running a sixth enumeration round, so treat the first finding as a design decision rather than an open question.

Findings

  • [P2] Drop the guard's universal claim; pin the three id: rel steps instead
    scripts/test-resolve-release-tag.sh:59
    check_resolver_steps claims no step can write tag/version outputs by any mechanism, and I got six shapes past it against copied release.yml fixtures: >> "${GITHUB_OUTPUT}" (the sink regex needs a bare $ sigil), printf '%s=%s\n' tag "$EVIL" and the tag<<EOF delimiter form (both need a literal tag=), a step with shell: python writing os.environ["GITHUB_OUTPUT"], a new release job outside expected_jobs, and rebinding a consumer from ${{ steps.rel.outputs.version }} to ${{ inputs.docker_backfill_tag }} while leaving the resolver step untouched. It also fails in the other direction: a Compute cache key step doing echo "version=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" in docker-manifest is rejected as "writes tag/version outputs directly", and rewriting the resolver step to the equivalent one-line run: scripts/resolve-release-tag.sh "$TAG" is rejected as "does not invoke the tested resolver". Five rounds have each closed a spelling without shrinking the space, which is what a blacklist over shell embedded in YAML does. Anchor on structure instead: every tag/version consumer in release.yml reads steps.rel.outputs.*, there are exactly three id: rel steps, so pin those step blocks by content and require one per job. Twenty-five lines rejected all seven of your existing fixtures, passed the current tree, and did not false-red on either benign edit. On the six bypasses it catches three: the braced sink, the printf split key and the delimiter form. Correcting my own numbers here, since I first wrote four and had only run three of them. It misses shell: python, the unlisted job and the consumer rebind, and those three share the property that makes them worth stating: each leaves all three pinned blocks byte-identical, so the write just moves to a new step, a new job or the reader. A content pin sees edits to what it pins and nothing else. The other limit is that it fires on any edit inside a pinned step including a comment, which is the deliberate-update property rather than a bug. Say what it checks rather than restating the universal claim. Deleting check_resolver_steps and its fixtures outright and keeping the resolver unit tests is also fine by me; what I do not want is a sixth round of adding spellings.

  • [P3] Two fixtures do not exercise the clause they name. Recording this so it gets carried into whichever landing you pick, not asking for a separate fix on the current guard.
    scripts/test-resolve-release-tag.sh:204
    release-bypass deletes the resolver call line, so has_resolver_call hits 0 and it is rejected by the missing-call clause, not the output-injection clause. I rebuilt it without the "Decoy resolver call" step and got the identical rejection, so the decoy half proves nothing and the fixture duplicates release-commented-call. release-renamed-reverted at :386 is caught by the same generic direct-output clause as release-direct-output, tee and heredoc, not the per-job count clause its name implies. Both slip through because assert_guard_rejects discards stderr and checks only the exit status; taking an expected-message argument would have caught it. The other five fire the clause they claim.

  • [P3] Correct the guard claim in the description
    The body says the guard "Rejects same-step tag/version writes to $GITHUB_OUTPUT or $GITHUB_ENV regardless of output mechanism". Four of the six bypasses above are same-step writes, so that sentence needs to go whichever way the finding above lands. $GITHUB_ENV is in fact caught, but no fixture covers it.

On the CI point from the last two rounds: I approved the pending PR Checks run on 1fe33c7, release tag validation completed green, and the job wiring is right (pull_request and merge_group, pinned checkout, persist-credentials: false, contents: read).

Nothing in release.yml or the new checkout steps concerns me: all three are SHA-pinned with persist-credentials: false, docker-manifest gaining contents: read is the minimum for the checkout it now needs, and the shape check guarantees version starts with a digit, which rules out an argv-flag tag and a latest collision.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
scripts/test-resolve-release-tag.sh (2)

87-92: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Blank lines inside a step are silently dropped from the snapshot.

The in_step && !/^[[:space:]]*$/ guard skips empty lines, so the extracted text can never contain a blank line while the heredoc snapshot can't either. Harmless today, but it means whitespace-separated additions inside a step won't be represented exactly as written. Worth a short comment so the intent is obvious to the next reader.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-resolve-release-tag.sh` around lines 87 - 92, Add a brief
explanatory comment next to the `in_step && !/^[[:space:]]*$/` condition
documenting that blank lines are intentionally excluded from the extracted step
snapshot, so future changes preserve this behavior.

99-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Byte-exact snapshot pins unrelated step content.

The expected block includes the ghcr comment prose and the image= echo, so purely cosmetic edits (rewording a comment, reordering env: keys) fail CI with a diff that looks like a security regression. Consider narrowing the assertion to what the security property actually requires — each id: rel step's run invokes scripts/resolve-release-tag.sh and writes no tag=/version= line to $GITHUB_OUTPUT — and leaving the rest unpinned. This keeps the per-step binding from the earlier review without the maintenance drag.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-resolve-release-tag.sh` around lines 99 - 138, Update the
snapshot assertion in the expected resolver steps comparison to validate only
the security-relevant behavior for each job’s id: rel step: invoking
scripts/resolve-release-tag.sh and not writing tag= or version= values to
GITHUB_OUTPUT. Remove byte-exact pinning of unrelated comments, environment
ordering, and the docker image echo while preserving the per-step binding checks
for docker, docker-manifest, and npm-publish.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@scripts/test-resolve-release-tag.sh`:
- Around line 87-92: Add a brief explanatory comment next to the `in_step &&
!/^[[:space:]]*$/` condition documenting that blank lines are intentionally
excluded from the extracted step snapshot, so future changes preserve this
behavior.
- Around line 99-138: Update the snapshot assertion in the expected resolver
steps comparison to validate only the security-relevant behavior for each job’s
id: rel step: invoking scripts/resolve-release-tag.sh and not writing tag= or
version= values to GITHUB_OUTPUT. Remove byte-exact pinning of unrelated
comments, environment ordering, and the docker image echo while preserving the
per-step binding checks for docker, docker-manifest, and npm-publish.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b03c9572-a57d-436a-b4e3-9471fad68eb6

📥 Commits

Reviewing files that changed from the base of the PR and between 509f79f and d4ba70b.

📒 Files selected for processing (1)
  • scripts/test-resolve-release-tag.sh

@MikeTomlin19

Copy link
Copy Markdown
Author

Addressed the maintainer's structural-design decision in d4ba70b and made the snapshot whitespace-exact in b0c185f:

  • Removed the broad shell blacklist and all seven mutation fixtures.
  • Pinned the complete id: rel step blocks for exactly docker, docker-manifest, and npm-publish.
  • Updated the PR body to state the narrow structural guarantee and removed the universal output-mechanism claim.
  • Preserved blank lines in the extracted blocks so the snapshot is genuinely exact.

Validation:

  • bash -n scripts/test-resolve-release-tag.sh — passed
  • scripts/test-resolve-release-tag.sh — passed
  • git diff --check — passed

@beardthelion
beardthelion dismissed their stale review July 29, 2026 02:07

Superseded by a re-review on b0c185f.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolver is settled and I am not reopening it. On b0c185f the original payload still rejects with a zero-byte $GITHUB_OUTPUT, gutting the character allowlist reds the suite, widening v[0-9]* to v* reds it, and putting + back reds it. The rewrite is the right call: deleting the blacklist and its seven fixtures for a content pin cost -342/+71 and closed every shape that beat the old guard. Brace group, printf split key, >> "${GITHUB_OUTPUT}", tee -a, the tag<<EOF delimiter form, a re-indented step, id: moved to the first line, a duplicate id: rel, and even a comment-only edit inside a pinned block all fail it now. The false-red I flagged last round is gone.

One gap survives, and it is in the design I settled, not in your implementation of it.

Findings

  • [P2] Pin the checkout step that delivers the resolver, or the pinned block's code is not pinned
    scripts/test-resolve-release-tag.sh:59
    The pinned block's whole behavior is one line, scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}", and that file arrives from the Check out workflow scripts step immediately above, which the extractor never emits. I ran three substitutions against copied fixtures and the suite exits 0 on all three: adding ref: ${{ inputs.docker_backfill_tag }} to the checkout with:, repointing it to repository: attacker/evil, and deleting the step outright. The first two swap the validator for caller-chosen content in a job holding packages: write; the third breaks the release at runtime with exit 127 while CI stays green. This is not one of the three limits I accepted last round, which move the write elsewhere; this replaces the code being pinned. Extending the extractor to also emit steps named Check out workflow scripts closes it: three awk lines plus the regenerated expected block, 22 lines total. I ran it, and it reds all three substitutions plus an unpinned actions/checkout@v4, stays green on a benign Compute cache key step and on an unrelated rename in release-binaries, and still reds the charset, shape, + and brace-group mutations. Deleting the pin entirely and keeping only the resolver unit tests remains fine by me. What I do not want is another round of enumerating spellings, and this is not one.

  • [P3] release tag validation still has not run on this head
    .github/workflows/pr-checks.yml:51
    PR Checks on b0c185f is action_required, so the rollup's green is triage only. The run I approved last round was on 1fe33c7, which carried the old guard, so the rewritten suite has never executed in CI. That one is mine to approve rather than yours to fix, and I am approving it alongside this review.

Three things I am recording, none of them asks, all pre-existing and none introduced here. Please do not change them in this PR. The shape check accepts v1atest and v1.2.3.4 while its own error text says vX.Y.Z, so the description's "bad-shape inputs" claims more than the code does, and the wording is the only thing worth touching. docker-manifest re-points :latest and MAJOR_MINOR unconditionally, so backfilling an older tag walks :latest backwards, and an rc tag resolves 1.2.3-rc.1 into a :1.2.3-rc moving tag; I ran both. And ref: ${{ steps.rel.outputs.tag }} takes an unqualified ref, which actions/checkout resolves as a branch before a tag, so a branch named v9.9.9 shadows the tag of that name. All three need write access, the same actor who can already dispatch, so none of them crosses a boundary this PR claims to hold. I will file them separately.

Nothing else in release.yml concerns me. All three checkouts are SHA-pinned with persist-credentials: false and no ref:, docker-manifest gaining contents: read is the minimum for the checkout it now needs, top-level permissions: {} holds, and every $GITHUB_OUTPUT writer in the file is either a validated resolver output or a workflow literal.

@MikeTomlin19

Copy link
Copy Markdown
Author

Addressed beardthelion's checkout-pin finding in 89e17e0. The structural snapshot now pins each Check out workflow scripts block alongside its id: rel step; the guard passes and fails when a checkout is mutated to a caller-controlled ref.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rechecked head 89e17e05 after the drift question. The #239 fix still holds on the current tree: the shared resolver rejects the original multiline payload with a zero-byte $GITHUB_OUTPUT, and beardthelion's checkout-pin ask from b0c185f looks addressed — the structural snapshot now pins each Check out workflow scripts block with its id: rel step and reds checkout ref/repository mutations and checkout deletion. I do not see a live reopening of the injection bug.

What remains is one real process gap, not a code defect.

Findings

  • [P3] release tag validation still has not reported on the current head
    .github/workflows/pr-checks.yml:51
    This is a CI-evidence gap, not a resolver or pin regression. On 89e17e05, GitHub's rollup shows only Quality-signal triage and CodeRabbit; PR Checks for that SHA completed as action_required with no finished jobs, so the new regression suite has not executed in the environment it exists for. Local scripts/test-resolve-release-tag.sh passes on this checkout. Please get a green release tag validation run on the latest head before merge. Fork PR workflow approval is the usual fix here — beardthelion approved/reran this on earlier heads, but not yet on 89e17e0.

Recorded, not asks for this PR

I looked again at the other threads that can read like findings. None of them reopen #239 on today's diff, and beardthelion asked not to expand scope on several of them here.

  • Description drift, not a new bug: loose v[0-9]* still accepts shapes like v1atest and v1.2.3.4 while the error text says vX.Y.Z. That is pre-existing prefix behavior tightened by charset/newline guards, not a regression from this PR. Wording-only follow-up if you want the body to match the code.
  • Accepted pin limits, not live bypasses: rebinding consumers away from steps.rel.outputs.*, adding a rogue job outside the three pinned jobs, or writing tag/version in an unpinned step can still pass the snapshot while the pinned blocks stay byte-identical. That is the narrow structural guarantee you documented; deleting the pin and keeping resolver unit tests remains a valid maintainer choice.
  • Pin hole, correct on head: dropping docker-manifest's new contents: read would break the script checkout at runtime while the snapshot stays green. Worth knowing if you extend the pin later; the permission is correct in the current diff.
  • Intentional tightening: +build rejection is deliberate, tested, and scoped to dispatch/backfill hygiene.
  • Out of scope / pre-existing: unvalidated release-please outputs on release-binaries/web-sync, branch-before-tag checkout resolution, and backfill :latest behavior were already outside this PR's claimed boundary or pre-date it.

If the bar is "fix #239 in today's workflow," the resolver and checkout pin are there. If the bar is "green CI on the merge candidate SHA," the item above is the remaining work.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The #239 fix holds on 89e17e0 and the checkout-pin ask from b0c185f landed exactly as scoped, +22 lines and nothing else.

On this head the original payload rejects with a zero-byte $GITHUB_OUTPUT. Sixteen mutations run: gutting the character allowlist, widening v[0-9]* to v*, re-admitting +, admitting /, and replacing the resolver with the pre-PR unvalidated echo all red the suite. So do all four checkout substitutions (attacker-chosen ref:, repository: attacker/evil, deleting the step, unpinning to @v4), reverting the docker job to inline echo "tag=...", collapsing the resolver step to a one-line run:, and a comment-only edit inside a pinned block. It stays green on an unrelated job rename and on a benign version= write in an unpinned step, so the false red I flagged two rounds ago has not returned. Actionlint 1.7.7 is clean on both workflows.

release tag validation is green on 89e17e0. I approved the pending run; that was mine to clear, not yours, and it closes the CI-evidence gap jatmn raised on the last three heads.

Findings

  • [P3] Say that the pin covers the checkout blocks too
    scripts/test-resolve-release-tag.sh:60
    The description calls it pinning "the complete id: rel step blocks in exactly three jobs", and the verification section says it compares "the complete three resolver step blocks". It pins six blocks now, each Check out workflow scripts step alongside its id: rel step, which was the whole point of the last round. Wording only, and it should not pick up a universal claim on the way past. This is the only thing I am asking for and it should not hold the merge.

Two limits worth knowing, neither an ask. A step inserted between the pinned checkout and the pinned id: rel step can rewrite scripts/resolve-release-tag.sh before it runs, with all six blocks byte-identical and the suite at exit 0; I ran it. I am not asking you to close it: the four checkout spellings I did ask for are things a maintainer does by accident, and a sed -i on the validator is not drift, so a content pin is the wrong instrument for it. A job-level defaults.run.shell wrapper is the same class. Second, : "${GITHUB_OUTPUT:?...}" is the one resolver branch the suite does not exercise; deleting that line leaves the suite green. It fails closed, so I am recording it rather than asking.

docker and npm-publish now check out github.ref and then the release tag over the same workspace. That is correct because actions/checkout cleans and force-checks-out, but the default is load-bearing now in a way it was not before; I read that rather than ran it. The three pre-existing items from last round are unchanged and still out of scope here.

@kevincodex1 this one is ready.

@beardthelion
beardthelion dismissed their stale review July 29, 2026 14:47

Superseded by my approval on 89e17e0.

@beardthelion

Copy link
Copy Markdown
Collaborator

Two description edits whenever you make the P3 change, so it is one pass rather than two:

  • The pin sentence should say it covers the Check out workflow scripts blocks as well as the id: rel ones, in both "What changed" and "How a reviewer can verify".
  • In "Notes for reviewers", keep the first clause of the opening sentence, the one naming which events the job runs on, and delete the rest of that sentence. It is repo configuration rather than anything this PR changes, so it should come out entirely rather than be reworded.

@MikeTomlin19

Copy link
Copy Markdown
Author

Verified on current head 89e17e0: PR Checks, including release tag validation, are green. The PR body now describes the checkout-and-resolver block pairs in both requested sections, and the notes retain only the job-event scope. This resolves the two P3 follow-ups; no code change was needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

workflow-change Fork PR edits CI workflows — mandatory human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(release): unanchored tag validation glob lets a multiline dispatch input inject step outputs

3 participants