Skip to content

security(publish): move publish-state file out of repo cwd#797

Merged
BYK merged 1 commit intomasterfrom
security/move-publish-state-to-xdg
Apr 21, 2026
Merged

security(publish): move publish-state file out of repo cwd#797
BYK merged 1 commit intomasterfrom
security/move-publish-state-to-xdg

Conversation

@BYK
Copy link
Copy Markdown
Member

@BYK BYK commented Apr 21, 2026

Summary

Moves .craft-publish-<version>.json out of the repo checkout into $XDG_STATE_HOME/craft/. Today the state file lives inside cwd (the publish target's checkout); a committed file at .craft-publish-<X.Y.Z>.json — or any earlier CI step writing to cwd — 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.
    • The sha1(cwd) fragment disambiguates monorepo subpaths (e.g. packages/foo vs packages/bar at the same version).
    • When githubConfig is null (offline / non-GitHub), falls back to publish-state-<sha256(cwd)[:16]>-<version>.json. Still outside cwd.
  • getPublishStatePath(...) joins the two.

src/commands/publish.ts

  • Resolve the GitHub config up front (already cached) and pass it to getPublishStatePath.
  • mkdirSync(dirname(publishStateFile), { recursive: true }) before the first write.
  • Warn (but don't read) when a legacy .craft-publish-<version>.json is detected in cwd, so users running outside getsentry/publish know 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 inside cwd.
  • Existing publish.test.ts tests unchanged and still pass.

Rollout plan

This is one half of a two-PR coordinated change. Merge order:

  1. Merge the getsentry/publish PR first. It dual-writes to both the legacy and new locations. Current Craft keeps reading the legacy location → works.
  2. Merge this PR and ship a new Craft release (+ bump the getsentry/craft:latest Docker tag). Craft now reads only the new location. publish.yml is still writing both, so the new run finds the new file. Works.
  3. After a few publish runs succeed on the new Craft, merge a follow-up on getsentry/publish that 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.
  • Manual cross-check: the bash filename computation in the publish workflow produces the same string as getPublishStateFilename() for both root and monorepo paths:
container_cwd: /github/workspace/__repo__/packages/browser
Craft: publish-state-getsentry-sentry-javascript-dbd7df897997-1.2.3.json
Bash:  publish-state-getsentry-sentry-javascript-dbd7df897997-1.2.3.json

Security property under test

getPublishStatePath(..., cwd) must never return a path that is a descendant of cwd. Both publishState.test.ts tests assert this invariant for both the GitHub-config branch and the fallback branch.

.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.
@BYK BYK marked this pull request as ready for review April 21, 2026 17:39
Comment thread src/commands/publish.ts
@BYK BYK merged commit 98052d9 into master Apr 21, 2026
16 checks passed
@BYK BYK deleted the security/move-publish-state-to-xdg branch April 21, 2026 17:55
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.
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.

1 participant