Commit 05b7a79
fix(ci): depend on the SDK via workspace:^ so releases tag the version bump (#1619)
Closes
[SDK-298](https://linear.app/e2b/issue/SDK-298/release-tags-point-at-the-commit-before-the-version-bump).
Replaces #1615, which moved the tags after the fact instead of removing
the reason they were misplaced.
## The bug
Every published release tag pointed at the commit *preceding* its own
version bump:
```console
$ git show '@e2b/python-sdk@2.35.0:packages/python-sdk/pyproject.toml' | head -3
[project]
name = "e2b"
version = "2.34.0" # ← tagged 2.35.0
```
Anything that builds from a git tag rather than a registry got the
previous release: distro packagers, `pip install git+…@tag`, any bisect
over a release regression. `python3Packages.e2b` in nixpkgs shipped
1.5.0 as 1.5.1 from June 2025.
## Root cause: a dependency cycle
`changeset publish` tags whatever commit it publishes from, so the fix
is to commit the version bump first. That was impossible:
```
tag must point at → release commit
release commit must contain → pnpm-lock.yaml
pnpm-lock.yaml contains → integrity hash of a tarball this release uploads
```
`packages/cli` depended on `e2b` by registry range, so `changeset
version` rewrote that range and the lockfile had to be re-resolved
against a tarball that did not exist yet. The lockfile could only be
refreshed *after* publishing, which forced the commit — and therefore
the tags — after it too.
## The fix
`packages/cli`: `"e2b": "^2.36.1"` → `"e2b": "workspace:^"`.
The lockfile now records `link:../js-sdk` and stops changing at release
time, so the release commit is complete before anything is uploaded:
| | before | after |
|---|---|---|
| 1 | `pnpm run version` | `pnpm run version` |
| 2 | publish **+ tag** ← wrong commit | **commit** (local) |
| 3 | refresh `pnpm-lock.yaml` (retry ≤6×) | publish **+ tag** ← right
commit |
| 4 | commit + push | push |
That deletes the lockfile-refresh step and its whole
registry-propagation retry loop (#1589), and `createGithubReleases:
true` keeps doing the tagging and GitHub releases — no custom tagging
code. Keeping the commit local also improves recovery: a publish that
uploads *nothing* leaves the branch untouched with the changesets
intact, so re-dispatching retries cleanly.
### Landing that commit is now mandatory, so the push is resilient
Once the tags point at a local commit, getting it onto the branch stops
being bookkeeping. `changesets/action` pushes each tag as soon as
`changeset publish` reports it (`runPublish` → `git.pushTag`), *before*
it propagates a non-zero exit — so three things changed:
- **The push is gated on the tags themselves** — `git tag --points-at
HEAD` — not on whether the publish step succeeded. The tags are the
thing that has to end up reachable, so they are the right thing to ask.
A partial failure (npm succeeds, then python-sdk's `postPublish` fails
on PyPI) used to skip the push and strand tags on a commit that reached
no branch while `main` kept the old versions.
I first wrote this as `!cancelled() && (success() ||
steps.release.outputs.published == 'true')`, which was wrong in both
directions: `success()` fires in exactly the case that must be skipped
(publish exits 0 having uploaded nothing → pushes a bump with no tags,
cementing a version that can never be published), and `published` is
left unset when the action *throws* after tagging (`core.setOutput` runs
only on a normal return from `runPublish`, but `git.pushTag` happens
inside it) — so it was skipped in the very case it existed for. The tag
gate also covers `@e2b/python-sdk`, which the npm-derived output never
did, since `privatePackages.tag` is on.
- **A partial publish is reported, not swallowed.** It still has to land
— otherwise the pushed tags hang off no branch — but the bump is then on
the branch with the changesets consumed, so re-dispatching will not
retry what failed. The step now names the tags that did land and points
out that `postPublish`'s PyPI upload was skipped (the root script is
`changeset publish && ... postPublish`, so a non-zero npm exit
short-circuits it).
- **A non-fast-forward is reconciled with a merge,** not a rebase (which
would orphan the tags) and not a hard failure. Hard-failing left an
already-published release needing manual git surgery, and a naive
re-dispatch would publish nothing (versions already on the registry),
tag nothing, and report **success** — quietly recreating SDK-298.
- **`git add -A` replaces `commit -am`,** which cannot stage new files.
`changeset version` writes each `CHANGELOG.md` fresh, so no release
commit has ever contained one:
```console
$ git show --stat cf8296c | tail -4
.changeset/lucky-pandas-wave.md | 5 ---
packages/cli/package.json | 4 +-
packages/js-sdk/package.json | 2 +-
pnpm-lock.yaml | 77 +-----------------
```
## The published packages do not change
`pnpm publish` (which `changeset publish` uses in a pnpm workspace)
rewrites the protocol. Verified on the real CLI package with the
workspace SDK at 9.9.9:
```
e2b dependency -> ^9.9.9
PASS: no workspace: in published manifest
```
## Verification
| check | result |
|---|---|
| `pnpm install --frozen-lockfile` on a clean clone | consistent |
| `pnpm run version` touches the lockfile? | **no** — `git diff
pnpm-lock.yaml` empty after bumping sdk 2.36.1→2.36.2, cli→2.16.1 |
| tags land on the release commit | `PASS e2b@2.36.2`, `PASS
@e2b/python-sdk@2.36.0`; tagged trees contain `"version": "2.36.2"` /
`version = "2.36.0"` |
| CLI still bumped when the SDK is | yes, `updateInternalDependencies`
still sees the internal dep |
| CLI typecheck / tests | clean / 102 passed (1 pre-existing failure
needs `E2B_API_KEY` + a built `dist`) |
| CLI bundle | builds, contains the workspace SDK, zero external
`require("e2b")` |
| `pnpm publish` git checks | `changeset publish` passes
`--no-git-checks` for pnpm ≥5 (repo pins 9.15.5); added explicitly to
the RC flows, which publish from a feature branch with an uncommitted
bump |
| `prepack` guard | `npm pack` fails and produces no tarball; `pnpm
pack` passes and rewrites to `^2.36.1` |
| `pnpm publish` lifecycle | runs `prepublishOnly` + `prepack` +
`prepare`, so the RC still builds; `pnpm pack` runs only `prepack` +
`prepare` (0.37 s, no rebuild) |
| `pnpm publish --provenance` | flag accepted (pnpm forwards to the npm
publish it spawns) |
| `pnpm link --global` | links the workspace CLI (2.16.0) and resolves
`workspace:^`; tested against an isolated `PNPM_HOME` |
| `pnpm version` / `pnpm pkg` | pnpm forwards both to npm verbatim, so
these are the same code path as before — neither resolves dependencies,
so `workspace:` is inert there |
| lockfile vs `exclude-links-from-lockfile=true` | `--frozen-lockfile`
green with the new `link:../js-sdk` entry, including after a
release-style version bump |
## Everything packs and publishes with pnpm
Only pnpm rewrites `workspace:`. `npm pack` copies the protocol into the
tarball verbatim and `npm install` then refuses it. Rather than hand-pin
the range back before each npm call, every flow that produces or
installs a CLI tarball now uses pnpm:
| flow | before | after |
|---|---|---|
| `pkg_artifacts.yml` | `npm pack` | `pnpm pack` |
| `publish_candidates.yml` | `npm publish --provenance` | `pnpm publish
--provenance --no-git-checks` |
| `.github/actions/build-cli` | `npm install -g .` | `pnpm link
--global` |
The `build-cli` action was a **third** npm consumer of the manifest,
missed on the first pass. It only worked because npm symlinks a local
directory for `-g` without resolving its dependencies at all — verified:
a control package depending on `chalk` installed with exit 0 and chalk
was never fetched. Force packing (`install_links=true`) and it dies with
`EUNSUPPORTEDPROTOCOL`.
Moving the rewrite to pack time changes *when* it resolves, which
matters in `pkg_artifacts.yml`: `pnpm pack` uses whatever version the
workspace SDK has at that moment, and that job renames the SDK to an
unpublished prerelease. Packing the CLI first was required —
```console
# SDK renamed first (wrong order)
CLI packed with e2b -> ^2.36.2-fake-branch.0 # never published → ETARGET
# CLI packed first (as merged)
e2b-cli-2.16.1-fake-branch.0.tgz -> e2b: ^2.36.1 # published, resolvable
```
`publish_candidates.yml` needs the opposite order and already had it:
the SDK RC *is* published first, so the CLI correctly pins that RC.
`--no-git-checks` is new there — candidates are cut from a feature
branch with the version bump uncommitted, so `pnpm publish` would
otherwise refuse.
### Not enforced, deliberately
I went down a path here and backed out of it, so it is worth recording.
I first added a
`prepack` guard on `packages/cli` that refused to build a tarball for
any packer but
pnpm. It had three bypasses: `npm_config_user_agent` is inherited, so
npm spawned from
pnpm still reports `pnpm/…` and sailed through it; and
`--ignore-scripts` and
`npm install -g <dir>` never run lifecycle scripts at all. I then
replaced it with a
step that installed the packed tarball with npm on every PR, which did
cover all of
those (verified: it rejects an `npm pack` tarball with
`EUNSUPPORTEDPROTOCOL` while
`pnpm pack` resolves the range to `^2.36.1`).
Both are now gone, in favour of keeping this PR to its actual subject.
So the rewrite is
unverified: the existing flows all use `pnpm pack`/`pnpm publish`, and
`changeset publish` picks pnpm by detecting the workspace, so it happens
— but nothing
catches it if a future flow reaches for npm instead. The tarball-install
step is a cheap
seven lines if we later decide we want it.
## Behavior change worth knowing
CLI tests previously resolved `e2b` from `node_modules`, i.e. the
*previously released* SDK, while `tsconfig.json` and the tsdown bundle
already used `../js-sdk/src`. `vitest.config.ts` now has a matching
alias, so all three agree and tests exercise the SDK that ships. The
alias is load-bearing — without it the workspace package's `main`
(`dist/index.js`) doesn't exist until the SDK is built:
```
Error: Failed to resolve entry for package "e2b".
⎯⎯⎯⎯⎯⎯ Failed Tests 11 ⎯⎯⎯⎯⎯⎯⎯
```
A broken SDK in the tree now fails CLI tests. `cli_tests.yml` and
`pkg_artifacts.yml` already built the SDK before the CLI, so no CI
ordering changed.
## Not retagging the past
Tags up to `e2b@2.36.1` / `@e2b/cli@2.16.0` / `@e2b/python-sdk@2.35.0`
stay off by one — moving published tags breaks anyone who pinned them.
**Build those versions from the npm tarball or the PyPI sdist, not from
the git tag.** That matters for distro packagers: `python3Packages.e2b`
in nixpkgs shipped 1.5.0 as 1.5.1 for exactly this reason. This caveat
is recorded here and in [SDK-298](https://linear.app/e2b/issue/SDK-298)
rather than in the repo.
## Follow-up
SDK-298 also notes `packages/python-sdk/pyproject.toml` pins
`uv_build>=0.10.0,<0.11.0`, so packagers on uv 0.11.x must patch it to
build at all. And `e2b-dev/code-interpreter` has the same tag bug in its
own publish workflow.
> **Corrections to earlier versions of this description:**
>
> 1. It suggested demoting `e2b` to a `devDependency` since the bundle
inlines it. That breaks the CLI — but *not* for the reason given next.
> 2. It then claimed `tsdown.config.ts` derives `alwaysBundle` from
`dependencies`, so removing `e2b` makes it *external* and the CLI ships
a bare `require("e2b")`. **That is backwards.** tsdown externalizes
exactly the production dependencies (`getProductionDeps` = `dependencies
∪ peerDependencies ∪ optionalDependencies`), so listing `e2b` there is
what would externalize it; `alwaysBundle` exists to cancel that. A
devDependency is *also* inlined. Verified with a control: moving `e2b`
to `devDependencies` still emits zero `require("e2b")`, while adding it
to `excludedPackages` is what produces the bare require and drops the
bundle from 2.14 MB to 1.84 MB.
>
> The real reason it must stay a dependency is runtime resolution: the
SDK reaches `undici`, `glob` and `tar` through `dynamicImport`, which is
deliberately opaque to bundlers, so they resolve from `node_modules` at
run time. The CLI declares none of them and gets all three via `e2b`:
>
> ```
> undici: present undici8: present glob: present tar: present
> ```
>
> Without them `e2b template build` loses `glob`/`tar` and
`loadUndici()` returns `undefined`, silently downgrading every request
to the global `fetch` and giving up H2 and proxy support. So: **do not
demote `e2b` to a devDependency.** This warning lives only here — there
is no in-repo note for it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 2c061eb commit 05b7a79
8 files changed
Lines changed: 83 additions & 75 deletions
File tree
- .changeset
- .github
- actions/build-cli
- workflows
- packages/cli
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
38 | 41 | | |
39 | 42 | | |
40 | | - | |
| 43 | + | |
41 | 44 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | 55 | | |
68 | 56 | | |
69 | 57 | | |
70 | 58 | | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
71 | 62 | | |
72 | 63 | | |
73 | 64 | | |
74 | | - | |
75 | | - | |
| 65 | + | |
| 66 | + | |
76 | 67 | | |
77 | 68 | | |
78 | 69 | | |
79 | 70 | | |
80 | 71 | | |
81 | 72 | | |
82 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
83 | 90 | | |
84 | 91 | | |
85 | 92 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
99 | | - | |
100 | | - | |
101 | | - | |
102 | | - | |
103 | | - | |
104 | | - | |
| 99 | + | |
| 100 | + | |
105 | 101 | | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
106 | 107 | | |
107 | 108 | | |
108 | 109 | | |
109 | 110 | | |
110 | 111 | | |
111 | 112 | | |
112 | 113 | | |
113 | | - | |
114 | | - | |
| 114 | + | |
| 115 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
74 | 95 | | |
| 96 | + | |
75 | 97 | | |
76 | 98 | | |
77 | 99 | | |
| |||
81 | 103 | | |
82 | 104 | | |
83 | 105 | | |
84 | | - | |
85 | | - | |
86 | | - | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
87 | 113 | | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
97 | | - | |
98 | | - | |
99 | | - | |
100 | | - | |
101 | | - | |
102 | | - | |
103 | | - | |
104 | | - | |
105 | | - | |
106 | | - | |
| 114 | + | |
| 115 | + | |
107 | 116 | | |
108 | 117 | | |
109 | 118 | | |
110 | | - | |
111 | | - | |
112 | | - | |
113 | | - | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
114 | 125 | | |
115 | | - | |
116 | | - | |
117 | | - | |
118 | 126 | | |
119 | 127 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
84 | | - | |
| 84 | + | |
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
16 | 20 | | |
17 | 21 | | |
18 | 22 | | |
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
0 commit comments