security(publish): move publish-state file out of repo cwd#797
Merged
Conversation
.craft-publish-<version>.json used to live in Craft's cwd, which is inside the repository being published. That means a PR to the repo could commit a file at that path and pre-populate the "already published" set, tricking Craft into silently skipping targets on the next publish — effectively a release-pipeline manipulation primitive (see AGENTS.md "Craft .craft-publish-<version>.json state file is unauthenticated"). Move the state file to $XDG_STATE_HOME/craft/ (falling back to $HOME/.local/state/craft/). The filename is keyed on owner, repo, a 12-char sha1 of cwd (to disambiguate monorepo subpaths), and the version. Repo contents cannot write to this path in getsentry/publish's Docker image (HOME=/root). When the GitHub config cannot be resolved (offline / non-GitHub test harnesses) the filename falls back to a sha256(cwd)-only form so Craft still refuses to write into the repo itself. Also: warn (but do not read) when a legacy .craft-publish-<version>.json is detected in cwd, to alert users / workflows that were pre-seeding state at the old location. The companion PR on getsentry/publish (https://github.com/getsentry/publish) adds dual-writes to both the legacy and new location for the duration of the transition.
BYK
added a commit
to getsentry/publish
that referenced
this pull request
Apr 21, 2026
## Summary Writes `.craft-publish-<version>.json` to BOTH the legacy cwd location (for currently-deployed Craft images) and a new safe location at `$XDG_STATE_HOME/craft/publish-state-<owner>-<repo>-<sha1(cwd)[:12]>-<version>.json`. Ships alongside [getsentry/craft#797](getsentry/craft#797), which moves Craft's read/write to the new location. ## Motivation Craft's state file previously lived inside the project's cwd — which is inside the repository being published. Any committed file at that path (or any earlier CI step) could pre-populate the "already published" set and silently skip targets on publish, a pipeline-manipulation primitive. See the `security(publish): move publish-state file out of repo cwd` PR on getsentry/craft. ## Changes ### `.github/workflows/publish.yml` → `Set targets` step - Render the payload once, reuse for both writes. - Compute the new safe path: `$GITHUB_WORKSPACE/.craft-state/craft/publish-state-<owner>-<repo>-<sha1(container_cwd)[:12]>-<version>.json`. This is mounted into the Craft Docker container at `/github/workspace/.craft-state/craft/` — outside `__repo__/`, unreachable from repo contents. - Write both locations. The legacy write will be removed in a follow-up once the new Craft release is in use. ### `Publish using Craft` step - Set `XDG_STATE_HOME=/github/workspace/.craft-state` so Craft resolves the same new-location path. ## Filename determinism The bash filename computation is designed to match `getPublishStateFilename()` in Craft exactly. Verified locally: ``` $ bash: printf %s '/github/workspace/__repo__/packages/browser' | sha1sum | cut -c1-12 → dbd7df897997 $ node: crypto.createHash('sha1').update('/github/workspace/__repo__/packages/browser').digest('hex').slice(0,12) → dbd7df897997 ``` Both root-path (`.`) and monorepo (`./packages/foo`) inputs produce the same filename in bash and Node. ## Rollout 1. **This PR**: dual-write. Current Craft still reads the legacy location; new Craft (after #797) reads the new location. Both work with this workflow change. 2. [getsentry/craft#797](getsentry/craft#797) + a Craft release + `:latest` bump. 3. Follow-up PR here: remove the legacy-location write. ## Verification - YAML parses (`python3 -c "import yaml; yaml.safe_load(...)"`). - Filename computation hand-simulated against both `.` and `./packages/foo` paths; matches Craft's output byte-for-byte. - No secret exposure: all env vars used in the step are already present in `fromJSON(steps.inputs.outputs.result)`. ## Why not just move to the new location immediately? Older Craft images (deployed via `docker://getsentry/craft:latest` today) expect the file at the legacy cwd path. Dropping the legacy write first would break publishes until the new Craft release rolls out to `:latest`, and would revert to breakage if anyone ever pinned to an older Craft tag. Dual-writes are zero-cost (a single extra `printf`) and make the transition safe.
7 tasks
BYK
added a commit
to getsentry/publish
that referenced
this pull request
Apr 21, 2026
## Summary Drops the legacy-location write that #7886 added as a rollout aid for [getsentry/craft#797](getsentry/craft#797). Craft 2.26.0 ships the new-location read/write and is now live on `getsentry/craft:latest`, so the dual-write is no longer needed. ## Context - **#7886** (merged): started dual-writing the publish-state file to BOTH the legacy cwd location AND the safe `$XDG_STATE_HOME/craft/` location, so both old and new Craft images could find it during the rollout. - **getsentry/craft#797** (merged, shipped in 2.26.0 at 2026-04-21T20:10Z): Craft reads/writes only the safe location. - **This PR**: drop the legacy write. The new-write logic is unchanged from what #7886 already exercises in production. ## Verification - Pulled the released `craft 2.26.0` binary, seeded a state file at the new location only, ran `craft publish 9.9.9` against a synthetic repo, and confirmed Craft logged `Found publish state file, resuming from there...` and honoured the `published` field to skip the target. End-to-end confirmation of the new-location read path with the 2.26.0 binary. - Bash filename generation and Craft's Node filename generation produce byte-identical strings — verified earlier (in #7886 description) and unchanged here. - YAML still parses (`python3 -c "import yaml; yaml.safe_load(...)"`). ## Diff 8 insertions, 17 deletions: the legacy block is removed, comment updated to name the actual Craft PR (#797) that shipped the reader, and the remaining variables lose their `new_` prefix since there's only one location now. ## Rollback If something regresses, revert this PR; #7886's dual-write code returns and both locations are written again. Old Craft images would still work off the legacy location.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Moves
.craft-publish-<version>.jsonout of the repo checkout into$XDG_STATE_HOME/craft/. Today the state file lives insidecwd(the publish target's checkout); a committed file at.craft-publish-<X.Y.Z>.json— or any earlier CI step writing tocwd— can pre-populate the "already published" set and trick Craft into silently skipping targets. That's a pipeline-manipulation primitive.Ships with a companion PR on
getsentry/publish: getsentry/publish#7886 (dual-writes to both the legacy and new location for the transition).Changes
src/utils/publishState.ts(new)getCraftStateDir()→$XDG_STATE_HOME/craft/or$HOME/.local/state/craft/.getPublishStateFilename(version, githubConfig, cwd)→publish-state-<owner>-<repo>-<sha1(cwd)[:12]>-<version>.json.packages/foovspackages/barat the same version).githubConfigis null (offline / non-GitHub), falls back topublish-state-<sha256(cwd)[:16]>-<version>.json. Still outside cwd.getPublishStatePath(...)joins the two.src/commands/publish.tsgetPublishStatePath.mkdirSync(dirname(publishStateFile), { recursive: true })before the first write..craft-publish-<version>.jsonis detected in cwd, so users running outsidegetsentry/publishknow to migrate.Tests
src/utils/__tests__/publishState.test.ts— 12 tests: XDG handling, filename schema, monorepo disambiguation, sanitisation, security invariant that the state file is never insidecwd.publish.test.tstests unchanged and still pass.Rollout plan
This is one half of a two-PR coordinated change. Merge order:
getsentry/publishPR first. It dual-writes to both the legacy and new locations. Current Craft keeps reading the legacy location → works.getsentry/craft:latestDocker tag). Craft now reads only the new location.publish.ymlis still writing both, so the new run finds the new file. Works.getsentry/publishthat drops the legacy-location write.Between steps 1–2 and 2–3, nothing regresses.
Verification
pnpm test— 12 new tests pass; existing publish.test.ts (18 tests) untouched.getPublishStateFilename()for both root and monorepo paths:Security property under test
getPublishStatePath(..., cwd)must never return a path that is a descendant ofcwd. BothpublishState.test.tstests assert this invariant for both the GitHub-config branch and the fallback branch.