Skip to content

prevent creating a release when the pinned js sdk version is differen… - #53

Merged
franzwarning merged 8 commits into
mainfrom
worktree-gate-release-on-play-sdk-pin
Aug 12, 2026
Merged

prevent creating a release when the pinned js sdk version is differen…#53
franzwarning merged 8 commits into
mainfrom
worktree-gate-release-on-play-sdk-pin

Conversation

@The64thRealm

Copy link
Copy Markdown
Contributor

…t from the one in the play repo

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @The64thRealm's task in 3m 5s —— View job


Code review

  • Gather context (diff, workflow file, repo conventions)
  • Review .github/workflows/auto-tag.yml gate logic
  • Review CLAUDE.md docs accuracy
  • Post findings

Nice shape overall — gating at tag time rather than at PR time is the right call, the App-token approach beats a PAT in a public repo, and the security trade-off is written down honestly in CLAUDE.md rather than glossed over. Five findings, posted inline:

# File Severity Finding
1 auto-tag.yml:70 High The documented recovery path can't re-trigger the workflow — a gate failure permanently blocks that version
2 auto-tag.yml:55 Medium jq lockfile path is brittle to hoisting / lockfileVersion / package-manager changes
3 auto-tag.yml:50 Low releases/latest 404 (no release, or draft/prerelease-only) dies with a bare gh error, no annotation
4 auto-tag.yml:44-62 Low Cheap local pin checks run after two network calls; missing pin file escapes the annotated error path
5 CLAUDE.md:11 Low New docs point at package-lock.json, but the JS SDK section and bump-sdk-js.sh --help still say package.json (a caret range)

#1 is the one worth fixing before merge. The workflow only fires on push to main with paths: ['Cargo.toml']. When the gate rejects a version bump, pushing the src/dev/sdk-js-version fix touches no Cargo.toml and runs nothing; re-running the failed run re-checks-out the same SHA and fails identically. So the release for that version can only be salvaged by a second version bump or a hand-pushed tag — and since ci.yml doesn't check the pin at PR time, any ordinary merge can land here. Adding src/dev/sdk-js-version to the trigger's paths makes the instructions in the error message true as written. Fix this →

Smaller things I checked and found fine: tr -d '[:space:]' parses as a POSIX class (not a literal char set), set -euo pipefail does catch the assignment-from-command-substitution failures, the raw media type handles a multi-MB package-lock.json, the null-string guard on line 64 correctly catches jq -r's output, and the credential pre-check gives a much better first-run experience than letting the token action fail cold.

One thing I couldn't verify from this environment (no network for the GitHub API): whether actions/create-github-app-token@v3 is a published major tag — worth a glance, since a bad tag would break the very release this gate protects.

Note: I only reviewed here, no code changes pushed. Also flagging that the checkout in this job has CLAUDE.md locally reverted to its main state — that's an artifact of my sandbox, not something in your branch.
· worktree-gate-release-on-play-sdk-pin

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 5m 37s —— View job


Code review

  • Gather context (diff, full workflow file, prior review round)
  • Review the PAT-based gate in .github/workflows/auto-tag.yml
  • Review CLAUDE.md docs accuracy
  • Post findings

The shell itself is in good shape — set -euo pipefail catches the command-substitution failures, tr -d '[:space:]' parses as a POSIX class, the vnd.github.raw accept header handles a multi-MB lockfile (the JSON contents API would base64 it and cap at 1MB), and the empty-GH_TOKEN pre-check fails closed with a message that names the secret and where it's documented. Swapping the GitHub App for a fine-grained PAT is a reasonable simplification given the expiry/ownership caveats are written down honestly in CLAUDE.md rather than glossed over.

Every finding is about how the gate meets the workflow's trigger and play's release semantics, not the script. Four, posted inline:

# File Severity Finding
1 .github/workflows/auto-tag.yml:55 High The documented recovery ("push the bump to retry the release") never re-triggers the workflow
2 .github/workflows/auto-tag.yml:35 Medium releases/latest skips drafts/prereleases and sorts by created_at — it isn't necessarily the release that fired release: published, so the gate can fail open
3 .github/workflows/auto-tag.yml:20 Medium The gate runs on every Cargo.toml push, so dependency-only edits that used to be green no-ops now hard-fail whenever play is ahead
4 .github/workflows/auto-tag.yml:36 Low A 404 on the lockfile fetch trips set -e with a bare gh: Not Found; the friendly error on line 50 only guards the jq result, not the fetch

#1 is the one worth fixing before merge. auto-tag.yml fires only on push to main with paths: ['Cargo.toml'], and there's no workflow_dispatch. When the gate rejects a bump, pushing the src/dev/sdk-js-version fix touches no Cargo.toml and runs nothing; re-running the failed run re-checks-out the same SHA and fails identically. So that version can only be salvaged by a second version bump or a hand-pushed tag — and since nothing checks the pin at PR time, any ordinary merge can land here. Adding src/dev/sdk-js-version to on.push.paths makes the error message and the CLAUDE.md instructions true as written; pin bumps on an already-released version just hit the existing || echo "Tag already exists" no-op. Fix this →

One doc nit not worth its own inline comment: the new Releases bullet says the source of truth is play's package-lock.json, but the JS SDK bullet directly above still says to keep the pin in step with play/package.json — which, as the workflow comment on line 39 notes, only carries a caret range. Worth pointing both at the lockfile.

I only reviewed here — no code changes pushed. Note I can't edit .github/workflows/** anyway (GitHub App permissions), so #1#4 need a human hand.
· worktree-gate-release-on-play-sdk-pin

devin-ai-integration[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 4m 38s —— View job


Code review

  • Gather context (diff, full workflow file, branch history)
  • Review the sdk-js gate in .github/workflows/auto-tag.yml
  • Review the CLAUDE.md change
  • Post findings

The shell is solid: set -euo pipefail makes every command substitution fail closed, the empty/null guards around the jq lookup are correct, tr -d '[:space:]' matches the .trim() in src/dev/server.rs so the pin can't mismatch on whitespace, vnd.github.raw avoids the JSON contents API's base64 + 1MB cap on a multi-MB lockfile, and the -z "$GH_TOKEN" pre-check works because an undefined secret expands to empty, not unbound. Gating at tag time rather than PR time is the right place for this.

Four findings, posted inline:

# File Severity Finding
1 .github/workflows/auto-tag.yml:55 High The documented retry path can't re-trigger the workflow, so a gated version stays permanently unreleased
2 CLAUDE.md:5 High a0a50cf over-reverted — it deletes the ## JS SDK section that was already on main
3 .github/workflows/auto-tag.yml:20 Medium The gate runs before Get version, so version-less Cargo.toml pushes that used to be green no-ops now hard-fail
4 .github/workflows/auto-tag.yml:27 Low The missing-PAT error points at a CLAUDE.md section this branch deletes

#2 is new since the last round and the easiest to miss. 931208d added a ## Releases section above the pre-existing ## JS SDK section; a0a50cf ("drop the CLAUDE.md changes") then removed both, plus the trailing newline. So the net effect of this PR on CLAUDE.md is deleting the only docs describing src/dev/sdk-js-version and scripts/bump-sdk-js.sh — the exact two things the new gate's error message tells a maintainer to go use. The inline comment carries a suggestion that restores it and drops CLAUDE.md from the diff entirely. Fix this →

#1 is still unaddressed from the previous two rounds. auto-tag.yml fires only on push to main with paths: ['Cargo.toml'], and there's no workflow_dispatch. When the gate rejects a bump, pushing the src/dev/sdk-js-version fix touches no Cargo.toml and runs nothing; re-running the failed run checks out the same SHA and fails identically. That version can then only be salvaged by a second version bump or a hand-pushed tag. Adding src/dev/sdk-js-version to on.push.paths makes the error message true as written — pin bumps on an already-released version just hit the existing || echo "Tag already exists" no-op. Fix this →

I only reviewed here — no code changes pushed. I can't edit .github/workflows/** anyway (GitHub App permissions), so #1, #3 and #4 need a human hand; #2 is a one-line revert I'd be happy to push if you want it.
· worktree-gate-release-on-play-sdk-pin

…e check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 4m 13s —— View job


Code review

  • Gather context (diff, full workflow file, repo conventions)
  • Review the sdk-js gate in .github/workflows/auto-tag.yml
  • Review the CLAUDE.md change
  • Post findings

The latest commit is a genuine improvement: gating on steps.tagged means a dependency-only Cargo.toml push no longer hard-fails when play happens to be ahead of the pin, which was the main false-positive risk in the previous round, and dropping the || echo "Tag already exists" swallow is now safe because the guard covers that case properly. The shell holds up too — set -euo pipefail does catch the command-substitution failures, tr -d '[:space:]' parses as a POSIX class (and the pin file really is 1.3.43\n, so it strips cleanly), the vnd.github.raw accept header avoids the contents API's base64 + 1MB cap on a multi-MB lockfile, and the tag format 0.1.5 lines up between ls-remote refs/tags/… and release.yml's tag glob.

Five findings, posted inline:

# File Severity Finding
1 CLAUDE.md:5 High The revert deletes the ## JS SDK section that already exists on main — a net doc regression against the merge base
2 auto-tag.yml:52 High The documented recovery ("push the bump to retry the release") never re-triggers the workflow
3 auto-tag.yml:46 Medium releases/latest excludes prereleases and sorts by created_at, so the gate can fail open
4 auto-tag.yml:49 Medium jq -er replaced the diagnosable error with a silent exit 1; lockfile path/hoisting assumptions block every release with no hint
5 auto-tag.yml:32 Low ls-remote conflates "no such ref" (exit 2) with a transport failure (128), now that the collision fallback is gone

#1 is new in this round and the easiest to miss. a0a50cf ("drop the CLAUDE.md changes") was aimed at the ## Releases section this PR added, but its diff also removes the ## JS SDK bullet that landed on main in #52git diff origin/main...HEAD -- CLAUDE.md shows it as a net deletion, along with the file's trailing newline. So a PR whose entire purpose is enforcing the sdk-js pin would land by deleting the docs describing that pin. Fix this →

#2 is carried over from both prior rounds and is still live. The trigger is still pushmainpaths: ['Cargo.toml'] with no workflow_dispatch, so the pin-only commit that scripts/bump-sdk-js.sh produces queues no run, and re-running the failed run re-checks-out the same stale SHA. The blocked version stays untagged until some unrelated Cargo.toml edit happens along. The new tagged guard is what makes the fix clean now — with it, adding src/dev/sdk-js-version to paths is a no-op on already-released versions. Fix this →

One thing folded into #1 rather than its own comment: the reverted text was the only place PLAY_READ_PAT was documented. grep -rn PLAY_READ now hits exactly one line in the whole repo (auto-tag.yml:41), so nothing records the required scopes, that fine-grained PATs expire, or that the token is tied to the account that minted it. And since the earlier empty-GH_TOKEN pre-check is gone too, an unset or lapsed secret blocks every release with a raw gh: Bad credentials and no annotation — the same diagnosability gap as #4, but on the failure mode that's guaranteed to happen eventually.

I only reviewed here — no code changes pushed. I can't edit .github/workflows/** anyway (GitHub App permissions), so #2#5 need a human hand; #1 is an ordinary file and could be pushed on request.
· worktree-gate-release-on-play-sdk-pin

devin-ai-integration[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

claude[bot]

This comment was marked as resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 4m 30s —— View job


Code review

  • Gather context (diff, full workflow file, branch history)
  • Review the sdk-js gate in .github/workflows/auto-tag.yml
  • Verify prior-round findings against the current head
  • Post findings

e2999ba fixed the doc regression — git diff origin/main...HEAD -- CLAUDE.md is now empty, so the ## JS SDK section survives and this PR is a clean single-file change. ✅ That closes the top finding from the previous round.

auto-tag.yml itself is byte-identical to the last round (4bda8dc), so I focused on angles not yet covered. Three new findings, posted inline:

# File Severity Finding
1 auto-tag.yml:43-44 Medium PLAY_READ_PAT is documented nowhere, and both of its inevitable failure modes die with no annotation
2 auto-tag.yml:32-36 Low Dropping || echo "Tag already exists" made the tag push non-idempotent — ls-remote 128-vs-2, and no concurrency: key
3 auto-tag.yml:52 Low scripts/bump-sdk-js.sh is mode 100644, so the remediation command this error prints doesn't run

#1 is the one I'd want resolved before merge. Reverting CLAUDE.md was right, but it took the PAT runbook with it, and 1d410df separately dropped the empty-GH_TOKEN pre-check that d9e029e had added. grep -rn PLAY_READ . now returns exactly one line — auto-tag.yml:41 — so nothing records that the token needs Contents:read on wvdsh/play, that fine-grained PATs expire, or whose account it hangs off. When it does lapse, line 46 dies on a bare gh: Bad credentials; if the secret is simply missing, gh says "set the GH_TOKEN environment variable" — while pointing at a step where GH_TOKEN visibly is set. Every release blocked, on a step whose name says play is ahead. The docs needn't go back in CLAUDE.md; the secret's description field would do. Fix this →

Still live from the earlier rounds (unchanged code, so I didn't re-post these inline — the existing threads stand):

  • [High] The trigger is still pushmainpaths: ['Cargo.toml'] with no workflow_dispatch, so the pin-only commit bump-sdk-js.sh produces queues no run and the blocked version stays untagged. The steps.tagged guard added in 1d410df is what makes the fix free now — adding src/dev/sdk-js-version to paths is a no-op on already-released versions.
  • [Medium] releases/latest excludes prereleases and orders by created_at, so it isn't necessarily the release that fired release: published — the gate can fail open.
  • [Medium] jq -er exits 1 with no output, so a lockfile layout change (workspaces/pnpm/lockfileVersion: 1) blocks every release with a bare Process completed with exit code 1.

Things I checked that hold up: set -euo pipefail plus pipefail does catch a failing gh on the line 47–49 pipe; tr -d '[:space:]' parses as a POSIX class and the pin file really is 1.3.43\n, matching the .trim() in src/dev/server.rs; vnd.github.raw sidesteps the contents API's base64 + 1MB cap on a multi-MB lockfile; git ls-remote works despite fetch-depth: 1 because actions/checkout persists credentials; the tag format (0.1.92, no v) lines up with release.yml's glob; and an empty VERSION from a malformed Cargo.toml no longer goes silently green — the pattern refs/tags/ matches no ref, so it reaches git tag -a "" and fails loudly, which is an improvement over the swallowed ||.

I only reviewed here — no code changes pushed. I can't edit .github/workflows/** anyway (GitHub App permissions), so all three need a human hand; the inline comments carry ready-to-apply suggestions.
· worktree-gate-release-on-play-sdk-pin

devin-ai-integration[bot]

This comment was marked as resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 6m 36s —— View job


Code review

  • Gather context (diff, full workflow file, branch history)
  • Review the sdk-js gate in .github/workflows/auto-tag.yml
  • Verify prior-round findings against the current head
  • Post findings

Two of the carried-over findings are fixed at this head: on.push.paths now includes src/dev/sdk-js-version and a workflow_dispatch trigger was added, so the retry the error message describes actually re-runs the gate; and CLAUDE.md is back to its origin/main contents, so it's out of the diff entirely. .github/workflows/auto-tag.yml is now the only changed file.

The core of the gate holds up. It's fail-closed, it runs before the tag push rather than after, set -euo pipefail makes every command substitution fail the step, tr -d '[:space:]' matches the .trim() in src/dev/server.rs so whitespace can't cause a false mismatch, and the vnd.github.raw accept header avoids the contents API's base64 wrapper and 1MB cap on a multi-MB lockfile. Gating on steps.tagged is the right call — a dependency-only Cargo.toml push no longer hard-fails just because play happens to be ahead of the pin.

Five findings, posted inline:

# File Severity Finding
1 auto-tag.yml:4 High workflow_dispatch has no branch guard — anyone with write access can cut a real release tag from a non-main branch
2 auto-tag.yml:48 Medium releases/latest excludes prereleases/drafts and orders by created_at, contradicting the comment above it
3 auto-tag.yml:49 Medium The lockfile path + hoisted-key lookup is an unverified assumption; any miss blocks every release with a bare jq/gh error
4 auto-tag.yml:34 Low git ls-remote --exit-code conflates exit 2 ("no such ref") with 128 (network/auth failure)
5 auto-tag.yml:63 Low Dropping the `

#1 is new in this round and is the one I'd fix before merge. The workflow_dispatch added to make the retry work has no github.ref guard, while the push trigger is restricted to main. The job checks out with HOMEBREW_TAP_TOKEN, reads version from whatever Cargo.toml is on the dispatched ref, and pushes that tag — which release.yml then builds and publishes to the tap. So a dispatch on any branch turns arbitrary unreviewed code into a published release, and the sdk gate doesn't help because that branch's pin is read too. A if: github.ref == 'refs/heads/main' on the job (or a if: github.event_name != 'workflow_dispatch' || github.ref == … guard) closes it while keeping the retry path. Fix this →

#3 and #5 are worth a decision, not necessarily a change. #3 (the package-lock.json path and the hoisted node_modules/@wvdsh/sdk-js key) I genuinely can't verify from here — I have no access to wvdsh/play. It fails in the safe direction, but every failure mode (pnpm/yarn, lockfileVersion: 1, workspace hoisting, an expired PLAY_READ_PAT) blocks all releases with a bare Process completed with exit code 1 and no ::error:: annotation, since jq -er prints nothing and the fetch isn't guarded. Given fine-grained PATs expire, that last one is guaranteed to happen eventually. #5 is the narrow race the removed || echo swallows used to cover — a concurrency: group is the one-liner.

Also worth noting: PLAY_READ_PAT isn't documented anywhere in the repo now that CLAUDE.md is reverted — grep -rn PLAY_READ hits only auto-tag.yml:41. Nothing records the scopes it needs, that it expires, or that it's tied to the account that minted it. Keeping that out of CLAUDE.md is fine, but the runbook should live somewhere.

Things I checked and found fine: the tag format (0.1.5, no v) lines up between ls-remote refs/tags/…, the push, and release.yml's tag glob; the if: steps.tagged.outputs.exists == 'false' string comparison is correct for step outputs; and the Get version grep/sed pair is unchanged from main.

I only reviewed here — no code changes pushed. I can't edit .github/workflows/** anyway (GitHub App permissions don't allow workflow modifications), so all five need a human hand.
· worktree-gate-release-on-play-sdk-pin

devin-ai-integration[bot]

This comment was marked as resolved.

Comment on lines +43 to +44
GH_TOKEN: ${{ secrets.PLAY_READ_PAT }}
run: |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PLAY_READ_PAT is now documented nowhere, and the failure mode it will eventually hit is undiagnosable.

Two things landed together across d9e029ee2999ba: the ## Releases prose that explained this secret was reverted (correctly — CLAUDE.md should be clean against main), and 1d410df dropped the empty-GH_TOKEN pre-check that d9e029e had added. Net result:

$ grep -rn PLAY_READ .          # excluding .git
./.github/workflows/auto-tag.yml:41

One hit, in the workflow itself. Nothing records that it must be a fine-grained PAT with Contents: read on wvdsh/play, that fine-grained PATs expire (so this will break on a date nobody has written down), or that it's bound to the account that minted it (so it dies when that person's access changes).

The failure is worse than just undocumented, because both variants die before the ::error:: on line 52 can fire:

  • Secret unset or deleted${{ secrets.PLAY_READ_PAT }} expands to the empty string, gh treats empty GH_TOKEN as unset, and line 46 dies with gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Whoever reads that looks at line 41, sees GH_TOKEN is set, and is stuck.
  • PAT expired → bare gh: Bad credentials (HTTP 401), no annotation, no mention of PLAY_READ_PAT.

Either way every release is hard-blocked, on a step named "Verify the pinned sdk-js matches the version play ships" — which reads like play is ahead, not like the token lapsed.

Restoring the pre-check is three lines and puts the secret's name and requirements in the one place someone will definitely be looking:

Suggested change
GH_TOKEN: ${{ secrets.PLAY_READ_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::PLAY_READ_PAT is unset. The release gate needs a fine-grained PAT with Contents:read on wvdsh/play, set as the PLAY_READ_PAT repo secret. Fine-grained PATs expire — if this used to work, reissue it."
exit 1
fi
pinned=$(tr -d '[:space:]' < src/dev/sdk-js-version)

That doesn't cover the 401 case, and it isn't a substitute for writing the runbook down somewhere — but "somewhere" needn't be CLAUDE.md; the secret's own description field or a line in the repo README would do.

shipped=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/wvdsh/play/contents/package-lock.json?ref=$tag" \
| jq -er '.packages["node_modules/@wvdsh/sdk-js"].version')
echo "cli pins $pinned; play $tag ships $shipped"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The remediation command in this message doesn't run as written — the script isn't executable.

$ git ls-files -s scripts/bump-sdk-js.sh
100644 cd62a35... 0	scripts/bump-sdk-js.sh

Mode 100644, not 100755. So the two ways someone would read "Run scripts/bump-sdk-js.sh 1.3.44" both fail:

  • ./scripts/bump-sdk-js.sh 1.3.44bash: ./scripts/bump-sdk-js.sh: Permission denied
  • scripts/bump-sdk-js.sh 1.3.44 → same (and command not found if they drop the path)

Only bash scripts/bump-sdk-js.sh 1.3.44 works. The missing bit is pre-existing (the file landed non-executable in #52, and CLAUDE.md has the same ./scripts/bump-sdk-js.sh wording), but this PR is what makes it load-bearing: this string is the only instruction a maintainer gets at the moment a release is blocked, and it's the first thing they'll paste.

Cheapest fix that makes both this message and CLAUDE.md true is git update-index --chmod=+x scripts/bump-sdk-js.sh in a separate commit. Failing that, spell out the interpreter here:

Suggested change
echo "cli pins $pinned; play $tag ships $shipped"
echo "::error::wavedash dev would inject @wvdsh/sdk-js@$pinned but play $tag ships $shipped. Run bash scripts/bump-sdk-js.sh $shipped, verify a game boots with wavedash dev, then push the bump to retry the release."

(Separately, "then push the bump to retry the release" is still not true — see the trigger-paths thread carried over from the earlier rounds.)

Comment on lines +32 to +36
id: tagged
run: |
if git ls-remote --exit-code origin "refs/tags/${{ steps.get_version.outputs.version }}" > /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping || echo "Tag already exists" made the tag push non-idempotent, and this guard doesn't fully replace it.

Gating on steps.tagged is the right call and it covers the common case. But the old || fallback was also absorbing two cases this if doesn't, and both now surface as a red X on main for a push that would previously have been a silent no-op:

1. ls-remote conflates "no such ref" with "couldn't reach the remote." Per the docs, --exit-code returns 2 when no matching ref is found; a transport or auth failure is a generic fatal, 128. Both are non-zero, so both take the else branch and set exists=false. On a transient blip against an already-tagged version, the job then runs the gate (which can itself hard-fail on a pin that drifted after that version shipped — an error about a release that already happened), creates the tag locally (actions/checkout defaults to fetch-depth: 1 with no tags, so nothing collides), and dies on git push origin <tag> with rejected — already exists.

2. There's no concurrency: key, so two runs can both see exists=false. Merge a version bump, then merge a dependency-only Cargo.toml edit 30 seconds later — both jobs read the same pre-tag state, both pass the guard, and the loser's push is rejected. Narrow, but this workflow's trigger makes back-to-back Cargo.toml pushes normal.

Neither is dangerous — nothing double-releases — but each is a failure notification on a no-op, which is exactly the noise 1d410df set out to remove.

Distinguishing 2 from 128 fixes (1) and makes the intent explicit:

Suggested change
id: tagged
run: |
if git ls-remote --exit-code origin "refs/tags/${{ steps.get_version.outputs.version }}" > /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
rc=0
git ls-remote --exit-code origin "refs/tags/${{ steps.get_version.outputs.version }}" > /dev/null || rc=$?
if [ "$rc" -eq 0 ]; then
echo "exists=true" >> $GITHUB_OUTPUT
elif [ "$rc" -eq 2 ]; then
echo "exists=false" >> $GITHUB_OUTPUT
else
echo "::error::Could not list tags on origin (git ls-remote exited $rc)"
exit 1
fi

For (2), a top-level concurrency: {group: auto-tag, cancel-in-progress: false} serialises the runs so the second one observes the first one's tag.

Comment thread .github/workflows/auto-tag.yml Outdated
name: Auto Tag on Version Change

on:
workflow_dispatch:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

workflow_dispatch with no branch guard lets this job cut a release tag from any branch. Dispatching the workflow with "Use workflow from branch: some-feature-branch" checks out that branch, reads its Cargo.toml version, and — if that version isn't tagged yet — pushes the tag, which fires release.yml (tag pattern **[0-9]+.[0-9]+.[0-9]+*) and publishes binaries plus the Homebrew formula from non-main code. Before this change only pushes to main could produce a tag. Worth pinning the job to main:

Suggested change
workflow_dispatch:
workflow_dispatch:

plus a job-level guard, e.g. if: github.ref == 'refs/heads/main'.

set -euo pipefail
pinned=$(tr -d '[:space:]' < src/dev/sdk-js-version)
# play deploys prod on `release: published`, so its latest release is what's live
tag=$(gh api repos/wvdsh/play/releases/latest --jq .tag_name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

releases/latest returns the most recent non-prerelease, non-draft release, ordered by the tag's created_at — which doesn't match the comment above it. release: published fires for prereleases too, so if play deploys prod from a prerelease (or from a release whose tag was created earlier than another tag, e.g. a backport cut later), this reads a different release than the one that is live. Concrete failure: play publishes prerelease v2.0.0-rc1 bundling sdk 1.4.0 and prod deploys it; a dev correctly bumps the CLI pin to 1.4.0; this step reads the older full release shipping 1.3.43, blocks the release, and tells the dev to downgrade the pin to a version prod no longer runs.

Comment on lines +49 to +51
shipped=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/wvdsh/play/contents/package-lock.json?ref=$tag" \
| jq -er '.packages["node_modules/@wvdsh/sdk-js"].version')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a fail-closed gate keyed on three unverified assumptions about a repo this one can't see: that play has a package-lock.json (npm, not pnpm/yarn/bun), that it's lockfileVersion 2/3 (v1 has no .packages), and that the sdk is hoisted to the top-level node_modules/@wvdsh/sdk-js key rather than a workspace path like apps/web/node_modules/@wvdsh/sdk-js. If any holds false, jq -er exits non-zero (or the contents call 404s) and every release is permanently blocked — with a bare jq/gh error, not the actionable ::error:: message below, and with a remediation ("bump the pin") that cannot possibly fix it. Note also that CLAUDE.md and scripts/bump-sdk-js.sh both name play/package.json as the source of truth, so the gate reads a different file than the docs. Consider failing with an explicit "could not determine the sdk version play ships" message when the lookup itself fails, so an unreadable play repo is distinguishable from a genuine mismatch.

- name: Check if version is already tagged
id: tagged
run: |
if git ls-remote --exit-code origin "refs/tags/${{ steps.get_version.outputs.version }}" > /dev/null; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

git ls-remote --exit-code returns 2 for "no matching ref" but 128 for a genuine failure (network blip, expired HOMEBREW_TAP_TOKEN, DNS), and both land in the else branch as exists=false. So an auth/transport failure is reported as "this version isn't tagged yet": the sdk gate runs, and the job then dies at git push origin <tag> with a confusing "tag already exists" rejection instead of surfacing the real cause. Distinguishing the codes would make the failure self-explanatory:

Suggested change
if git ls-remote --exit-code origin "refs/tags/${{ steps.get_version.outputs.version }}" > /dev/null; then
if git ls-remote --exit-code origin "refs/tags/${{ steps.get_version.outputs.version }}" > /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
elif [ $? -eq 2 ]; then
echo "exists=false" >> $GITHUB_OUTPUT
else
echo "::error::git ls-remote failed; cannot tell whether ${{ steps.get_version.outputs.version }} is already tagged"
exit 1
fi

Comment on lines +63 to +64
git tag -a "${{ steps.get_version.outputs.version }}" -m "Release ${{ steps.get_version.outputs.version }}"
git push origin "${{ steps.get_version.outputs.version }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping the || echo "Tag already exists" / || echo "Tag already pushed" fallbacks makes the check-then-push non-atomic and there's no concurrency: group on the workflow, so a benign duplicate now fails the job red. This is more likely after adding src/dev/sdk-js-version to the path filter, since two runs can now be in flight for the same Cargo.toml version: push A bumps the version to 0.1.93, push B (a pin bump, seconds later) contains that same version, both runs pass ls-remote before either reaches this step — the gate step adds several seconds of API latency in between — A pushes the tag and B dies on "tag already exists". A concurrency: { group: auto-tag, cancel-in-progress: false } block, or re-checking git ls-remote immediately before the push, would close the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 2m 34s —— View job


Code review

  • Gather context (diff, full workflow file, branch history)
  • Verify prior-round findings against the current head
  • Review the new commits (a214795, 2c24236)
  • Post findings

a214795 closed the finding that had been carried across four rounds — src/dev/sdk-js-version is in on.push.paths now, so the retry the error message describes actually queues a run and tags the still-pending Cargo.toml version. ✅ Combined with the steps.tagged guard, a pin bump on an already-released version skips both steps and goes green, which is exactly right.

The gate itself continues to hold up: fail-closed, runs before the tag push, set -euo pipefail (with pipefail catching a failing gh on the 49–51 pipe), tr -d '[:space:]' matching the .trim() in src/dev/server.rs, vnd.github.raw sidestepping the contents API's base64 wrapper and 1MB cap, and a tag format (0.1.92, no v) that lines up between ls-remote, the push, and release.yml's glob.

Three findings, posted inline. Two are consequences of this round's two commits.

# File Severity Finding
1 auto-tag.yml:4 High workflow_dispatch has no branch guard — a release can be cut from any branch
2 CLAUDE.md Medium 2c24236 deletes the whole file, including the docs this gate's error message points at
3 auto-tag.yml:10 Low The new path widens the check-then-push race; still no concurrency: key

#1 is the one I'd fix before merge. a214795 added workflow_dispatch: without a github.ref guard, while push stays restricted to main. On a dispatch, actions/checkout takes the ref chosen in the UI, the job reads version from that branch's Cargo.toml, and pushes the tag at that branch's HEAD with HOMEBREW_TAP_TOKENrelease.yml then builds and publishes it to the tap. The sdk gate doesn't help, because it reads the pin from the same unreviewed branch. Set version = "9.9.9" on a branch, dispatch, and it ships. A job-level if: github.ref == 'refs/heads/main' closes it and keeps dispatch-on-main as a manual retry — or drop the trigger entirely, since the widened paths already covers the documented recovery. Fix this →

#2 is a scope question more than a defect. e2999ba had restored CLAUDE.md to its main contents; 2c24236 then deletes the file outright — ## Project Purpose and the ## Development Doppler rule included, neither of which this PR touches. If that's deliberate it's a fine call, just a separate one from gating the release. Two knock-ons worth being deliberate about: line 54 tells a blocked maintainer to run scripts/bump-sdk-js.sh, and after this merges nothing in the repo explains that script or the never-@latest rule; and claude.yml / claude-code-review.yml read CLAUDE.md implicitly, so future Claude runs here lose the doppler run -- prefix instruction.

#3 is genuinely new because of a214795. Adding the pin file to paths means two runs can now be in flight for the same Cargo.toml version — a version bump merges, a pin bump merges 20 seconds later, both pass ls-remote before either reaches the push (the gate's two gh api calls sit in between), and the loser dies on rejected — already exists. That was a green no-op twice over before: no run was queued for pin pushes, and || echo "Tag already pushed" absorbed collisions. A concurrency: {group: auto-tag, cancel-in-progress: false} block serialises them.

On the threads you closed as won't-fix: the reasoning holds, and I'm not re-litigating them. Verifying play's lockfile is lockfileVersion 3 at the root with the hoisted key is exactly the check that makes the speculative-handling argument moot, and justincy/github-action-npm-release only cutting plain releases does make releases/latest correct — worth having that second fact as a comment on line 47, since the current wording ("play deploys prod on release: published") is what made three reviewers reach for the prerelease scenario. The ls-remote 128-vs-2 call is a fair trade too; #3 above is the same failure surfacing from a different direction, which is why it's still worth the concurrency one-liner.

One loose end from the previous round that no commit has touched: PLAY_READ_PAT is documented nowhere (grep -rn PLAY_READ hits only line 43), and both of its inevitable failure modes — unset secret, expired PAT — die before line 54 with a bare gh error and no annotation, on a step whose name says play is ahead. Fine-grained PATs expire, so that one is a matter of when. The secret's own description field would do; it needn't go back in the docs.

I only reviewed here — no code changes pushed. I can't edit .github/workflows/** anyway (GitHub App permissions don't allow workflow modifications), so #1 and #3 need a human hand.
· worktree-gate-release-on-play-sdk-pin

Comment thread .github/workflows/auto-tag.yml Outdated
name: Auto Tag on Version Change

on:
workflow_dispatch:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

workflow_dispatch with no branch guard turns "cut a release" into a button anyone with write access can press on any branch. (Raised in the previous round and by Devin; a214795 added the trigger without the guard, so re-flagging once.)

For workflow_dispatch, actions/checkout@v4 checks out the ref picked in the UI. The job then reads version from that branch's Cargo.toml (line 27), finds no matching remote tag, and pushes an annotated tag at that branch's HEAD (line 63-64) using HOMEBREW_TAP_TOKEN. release.yml fires on version-shaped tag pushes and publishes binaries plus the Homebrew formula. Before a214795, only a push to main could produce a tag.

The sdk gate is no help here — it reads the pin from the same unreviewed branch, so a branch that bumps both files sails through.

Concretely: open a branch, set version = "9.9.9", dispatch Auto Tag on it → 9.9.9 is tagged at unreviewed code and shipped to the tap.

A job-level guard keeps the manual retry path (dispatch on main) while restoring the main-only invariant:

jobs:
  tag:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

Worth deciding whether you want workflow_dispatch at all now that src/dev/sdk-js-version is in paths — the pin-only push covers the documented retry on its own, and dropping the trigger closes this without a guard.

Comment thread CLAUDE.md
- Environment variables are managed by Doppler. Always use `doppler run --` as a prefix when running cargo commands (build, check, clippy, run, test, etc.). For example: `doppler run -- cargo check`, `doppler run -- cargo clippy`.

## JS SDK
- `wavedash dev` injects `@wvdsh/sdk-js` from jsdelivr into the boot shell. The version is pinned in `src/dev/sdk-js-version` (one line, `include_str!`'d by `src/dev/server.rs`) — never `@latest`, so an SDK release can't change dev behaviour underfoot. Bump it with `./scripts/bump-sdk-js.sh` (no args = current npm `latest`), and keep it in step with the version play bundles in prod (`play/package.json`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2c24236 deletes CLAUDE.md outright — new this round, and it goes further than the e2999ba restore it replaced. The PR now removes the whole file, including the ## Development Doppler rule and ## Project Purpose, neither of which this PR touches.

If that's deliberate, it's a separate call from "gate the release on the sdk pin" and reads better as its own PR. Two consequences worth being deliberate about:

  1. The gate's own error message points at docs this commit deletes. Line 54 tells a blocked maintainer to Run scripts/bump-sdk-js.sh $shipped, verify a game boots with wavedash dev. After this merges, grep -rn "bump-sdk-js" finds only the script itself and this workflow — nothing explains that the pin must never be @latest, that it's include_str!'d by src/dev/server.rs, or that the script takes no args to mean npm latest. The one moment someone needs that context is the moment this gate fires.
  2. .github/workflows/claude.yml and claude-code-review.yml read CLAUDE.md implicitly. Deleting it means every future Claude run in this repo loses the doppler run -- instruction and will suggest bare cargo check.

Restoring the file (i.e. dropping 2c24236) makes this PR the single-file change it was at a214795.

- main
paths:
- 'Cargo.toml'
- 'src/dev/sdk-js-version'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adding this path is the right fix for the retry, but it also widens the check-then-push race that 1d410df opened, and there's still no concurrency: key on the workflow.

Two runs can now be in flight for the same Cargo.toml version, which wasn't possible before this line:

  1. A version bump 0.1.92 → 0.1.93 merges. Run 1: ls-remote says untagged → gate runs (two gh api round-trips to play, several seconds).
  2. A pin bump merges 20s later — which this line now makes a trigger. Run 2 reads the same 0.1.93, ls-remote still says untagged (run 1 hasn't pushed yet) → gate runs.
  3. Run 1 pushes 0.1.93. Run 2 reaches line 64 and dies on rejected — already exists.

That used to be a green no-op twice over: the pin push queued no run at all, and || echo "Tag already pushed" absorbed the collision. Now it's a red X and a failure notification on main for a commit that was correct.

Nothing double-releases, so this is noise rather than danger — but it's the same class of noise 1d410df set out to remove, and it's one key at the top of the file:

concurrency:
  group: auto-tag
  cancel-in-progress: false

That serialises the runs so the second one's ls-remote observes the first one's tag and skips cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 2m 34s —— View job


Code review

  • Gather context (diff, full workflow file, new commits since last round)
  • Verify prior-round findings against the current head
  • Review the new commits (8d5e6bb, 2c24236)
  • Post findings

8d5e6bb closed the top finding from the last two rounds: with workflow_dispatch gone, only a push to main can produce a tag again, so the release path can't be driven from an unreviewed branch. ✅ That was the right way to close it — the widened paths already covers the documented retry on its own, so the trigger wasn't buying anything.

The gate itself is unchanged since a214795 and still holds up. It's fail-closed, runs before the tag push, set -euo pipefail (with pipefail) makes the 48–50 pipe fail the step, tr -d '[:space:]' matches the .trim() in src/dev/server.rs and the pin file really is 1.3.43\n, vnd.github.raw sidesteps the contents API's base64 wrapper and 1MB cap, and the tag format (0.1.92, no v) lines up between ls-remote, the push, and release.yml's glob. Gating both steps on steps.tagged means a dependency-only Cargo.toml push no longer hard-fails when play is ahead.

One finding this round, posted inline — everything else is a carried-over thread that's still open against unchanged code, so I didn't duplicate it.

# File Severity Finding
1 CLAUDE.md Medium 2c24236 leaves bump-sdk-js.sh --help as the only surviving pin doc — and it names play/package.json, not the lockfile the gate reads

On #1. I raised the deletion last round as a scope question; this is the concrete consequence. There's no replacement in the tree — no AGENTS.md, and .claude/settings.json is settings, not instructions — so the last written account of the pin is the script's usage text, which says to keep it in step with play/package.json. The gate compares against package-lock.json because package.json only carries a caret range. So the blocked maintainer follows the error message to the script, reads its help, opens play's package.json, finds "^1.3.0", and the docs have pointed them away from the one usable value — $shipped, printed in the error itself. Two knock-ons in the same commit: claude.yml / claude-code-review.yml read CLAUDE.md implicitly, so future Claude runs lose the doppler run -- rule; and grep -rn PLAY_READ now hits exactly one line in the whole repo. If the deletion is deliberate that's fine, but those two pointers need to land somewhere before this merges. Fix this →

Still open from earlier rounds (unchanged code — the existing threads stand, not re-posting):

  • [Low] No concurrency: key, and adding src/dev/sdk-js-version to paths widened the check-then-push race: a version bump and a pin bump landing seconds apart both read exists=false (the gate's two gh api calls sit in between), and the loser dies on rejected — already exists. concurrency: {group: auto-tag, cancel-in-progress: false} is the one-liner.
  • [Low] PLAY_READ_PAT's two inevitable failure modes — unset secret, expired PAT — die at line 47 with a bare gh error and no ::error::, on a step whose name says play is ahead. Fine-grained PATs expire, so this one is a matter of when.
  • [Low] scripts/bump-sdk-js.sh is mode 100644, so the scripts/bump-sdk-js.sh $shipped the error tells you to run exits Permission denied; only bash scripts/bump-sdk-js.sh … works. git update-index --chmod=+x fixes it.

Things I re-checked and found fine: an empty VERSION from a malformed Cargo.toml still fails loudly rather than going silently green (refs/tags/ matches no ref, so it reaches git tag -a ""); git ls-remote works despite fetch-depth: 1 because actions/checkout persists credentials; a pin bump on an already-tagged version skips both steps and goes green; and the if: … == 'false' string comparison is correct for step outputs.

I only reviewed here — no code changes pushed. I can't edit .github/workflows/** anyway (GitHub App permissions), so the workflow items need a human hand; CLAUDE.md and the script's mode/help text are ordinary files I could push on request.
· worktree-gate-release-on-play-sdk-pin

Comment thread CLAUDE.md
- Environment variables are managed by Doppler. Always use `doppler run --` as a prefix when running cargo commands (build, check, clippy, run, test, etc.). For example: `doppler run -- cargo check`, `doppler run -- cargo clippy`.

## JS SDK
- `wavedash dev` injects `@wvdsh/sdk-js` from jsdelivr into the boot shell. The version is pinned in `src/dev/sdk-js-version` (one line, `include_str!`'d by `src/dev/server.rs`) — never `@latest`, so an SDK release can't change dev behaviour underfoot. Bump it with `./scripts/bump-sdk-js.sh` (no args = current npm `latest`), and keep it in step with the version play bundles in prod (`play/package.json`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Deleting this file leaves scripts/bump-sdk-js.sh --help as the only surviving documentation of the pin — and it names a different file than the gate reads.

I flagged the deletion last round as a scope question; this is the concrete consequence, and it's sharper than "docs got thinner."

After 2c24236 there is no replacement — no AGENTS.md, and .claude/settings.json carries settings, not instructions. So the last written description of the pin is the script's own usage text:

Keep the pin in step with the `@wvdsh/sdk-js` version play bundles in prod
(play/package.json), and verify a game boots with `wavedash dev` after bumping.

But the gate compares against play's package-lock.json (line 49), specifically because — as the removed comment on the previous revision of this line noted — package.json only carries a caret range. A maintainer whose release just got blocked follows the one instruction they're given (Run scripts/bump-sdk-js.sh $shipped), reads --help for the source of truth, opens play's package.json, sees "^1.3.0", and has nothing to pin to. The $shipped value in the error message is what actually saves them — the docs actively point away from it.

Two smaller knock-ons from the same commit:

  • ## Development and ## Project Purpose aren't yours to drop here. claude.yml and claude-code-review.yml pick up CLAUDE.md implicitly, so every future Claude run in this repo loses the doppler run -- prefix rule and will suggest bare cargo check — which build.rs needs env vars for. That's unrelated to gating the release.
  • PLAY_READ_PAT is now documented nowhere at all. grep -rn PLAY_READ hits exactly one line (auto-tag.yml:42). Nothing records that it needs Contents:read on wvdsh/play, that fine-grained PATs expire, or whose account it's bound to.

If dropping the file is deliberate, that's a fine call — but it's a different PR than "gate the release on the sdk pin," and the two doc pointers the gate depends on (bump-sdk-js.sh --help naming the lockfile, and the PAT runbook) need to land somewhere first. Reverting 2c24236 makes this the single-file change it was at a214795.

@franzwarning
franzwarning merged commit 0caa286 into main Aug 12, 2026
14 checks passed
@franzwarning
franzwarning deleted the worktree-gate-release-on-play-sdk-pin branch August 12, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants