Skip to content

Commit 05b7a79

Browse files
mishushakovclaude
andauthored
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/quiet-pugs-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@e2b/cli': patch
3+
---
4+
5+
Depend on `e2b` through pnpm's `workspace:^` protocol instead of a registry range. `pnpm publish` rewrites it to the same concrete `^<version>` it had before, so the published package is unchanged — but the lockfile no longer has to be re-resolved against the tarballs a release uploads, which is what forced the release tags onto the commit before their own version bump.

.github/actions/build-cli/action.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ runs:
3535
run: pnpm build
3636
working-directory: ./packages/cli
3737

38+
# `pnpm link`, not `npm install -g`: npm cannot resolve the `workspace:^` range
39+
# the CLI declares for `e2b`. It only gets away with it today because npm
40+
# symlinks a local directory without resolving its dependencies at all.
3841
- name: Install the CLI globally
3942
shell: bash
40-
run: npm install -g .
43+
run: pnpm link --global
4144
working-directory: ./packages/cli

.github/workflows/pkg_artifacts.yml

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,34 +52,41 @@ jobs:
5252
working-directory: packages/js-sdk
5353
run: pnpm run build
5454

55-
- name: Pack JS SDK
56-
working-directory: packages/js-sdk
57-
run: |
58-
npm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version
59-
npm pack
60-
61-
- name: Upload JS SDK artifact
62-
uses: actions/upload-artifact@v4
63-
with:
64-
name: e2b-js-sdk
65-
path: packages/js-sdk/*.tgz
66-
6755
- name: Build CLI
6856
working-directory: packages/cli
6957
run: pnpm run build
7058

59+
# Before the SDK is renamed below: `pnpm pack` resolves the CLI's `workspace:^`
60+
# range to whatever version the workspace SDK has now, and a prerelease that
61+
# was never published would leave `e2b` unresolvable.
7162
- name: Pack CLI
7263
working-directory: packages/cli
7364
run: |
74-
npm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version
75-
npm pack
65+
pnpm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version
66+
pnpm pack
7667
7768
- name: Upload CLI artifact
7869
uses: actions/upload-artifact@v4
7970
with:
8071
name: e2b-cli
8172
path: packages/cli/*.tgz
8273

74+
# Independent of the CLI steps above: a CLI failure should still leave
75+
# reviewers a usable SDK tarball, as it did before the CLI moved ahead of it.
76+
- name: Pack JS SDK
77+
if: ${{ !cancelled() }}
78+
working-directory: packages/js-sdk
79+
run: |
80+
pnpm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version
81+
pnpm pack
82+
83+
- name: Upload JS SDK artifact
84+
if: ${{ !cancelled() }}
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: e2b-js-sdk
88+
path: packages/js-sdk/*.tgz
89+
8390
- name: Install uv
8491
uses: astral-sh/setup-uv@v6
8592
with:

.github/workflows/publish_candidates.yml

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,20 @@ jobs:
9696
RC_PREID: ${{ inputs.preid }}
9797
RC_TAG: ${{ inputs.tag }}
9898
run: |
99-
npm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version
100-
npm publish --tag "$RC_TAG" --provenance
101-
102-
- name: Reinstall dependencies
103-
if: ${{ inputs.js-sdk || inputs.cli }}
104-
run: pnpm install --frozen-lockfile
99+
pnpm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version
100+
pnpm publish --tag "$RC_TAG" --provenance --no-git-checks
105101
102+
# `pnpm publish`, not `npm publish`: it resolves the `workspace:^` range the
103+
# CLI declares for `e2b` to a concrete version — here the RC the step above
104+
# just published, or the last release when only the CLI is being cut.
105+
# `--no-git-checks` because candidates are cut from a feature branch with the
106+
# version bump above still uncommitted.
106107
- name: Publish CLI RC
107108
if: ${{ inputs.cli }}
108109
working-directory: packages/cli
109110
env:
110111
RC_PREID: ${{ inputs.preid }}
111112
RC_TAG: ${{ inputs.tag }}
112113
run: |
113-
npm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version
114-
npm publish --tag "$RC_TAG" --provenance
114+
pnpm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version
115+
pnpm publish --tag "$RC_TAG" --provenance --no-git-checks

.github/workflows/publish_packages.yml

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,29 @@ jobs:
7171
env:
7272
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7373

74+
- name: Commit new versions
75+
# Commit before publishing, because `changeset publish` tags whatever HEAD
76+
# it publishes from — tagging afterwards is what left every tag on the
77+
# commit before its own version bump (SDK-298). Nothing in the tree
78+
# references the artifacts about to be uploaded, so the commit is already
79+
# complete. It stays local until something is actually published, so a
80+
# publish that uploads nothing leaves the branch untouched and the
81+
# changesets intact for a re-run.
82+
run: |
83+
git config user.name "github-actions[bot]"
84+
git config user.email "github-actions[bot]@users.noreply.github.com"
85+
86+
# `add -A`, not `commit -a`: `changeset version` writes each package's
87+
# CHANGELOG.md as a new file the first time, which `-a` would drop.
88+
git add -A
89+
if git diff --cached --quiet; then
90+
echo "::error::'changeset version' produced no changes, so there is no version bump to publish or tag."
91+
exit 1
92+
fi
93+
git commit -m "[skip ci] Release new versions"
94+
7495
- name: Release new versions
96+
id: release
7597
uses: changesets/action@v1
7698
with:
7799
publish: pnpm run publish
@@ -81,39 +103,25 @@ jobs:
81103
NPM_TOKEN: "" # See https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868
82104
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
83105

84-
- name: Update lock file
85-
# The npm registry is eventually consistent: versions published
86-
# seconds earlier may not resolve yet, so retry with backoff.
106+
- name: Push new versions
107+
# Gate on the tags rather than on whether the publish step succeeded: they
108+
# are what has to end up reachable. `changeset publish` tags at HEAD and the
109+
# step above pushes them to origin, so if any exist — even from a publish
110+
# that then failed partway — the commit they point at has to land, or they
111+
# hang off no branch, which is the SDK-298 breakage this change prevents.
112+
if: always()
87113
run: |
88-
for delay in 10 20 40 80 160; do
89-
pnpm i --no-link --no-frozen-lockfile && exit 0
90-
echo "Registry has not propagated the new versions yet, retrying in ${delay}s..."
91-
sleep "$delay"
92-
done
93-
pnpm i --no-link --no-frozen-lockfile
94-
95-
- name: Commit new versions
96-
run: |
97-
git config user.name "github-actions[bot]"
98-
git config user.email "github-actions[bot]@users.noreply.github.com"
99-
git commit -am "[skip ci] Release new versions" || exit 0
100-
101-
# A PR merging mid-release makes this push a non-fast-forward. The
102-
# artifacts were already published from the checked-out tree, so we
103-
# only rebase the version bump over incoming changes that don't touch
104-
# packages/. Otherwise the source would claim the published version
105-
# contains code that isn't in the artifacts, so we fail loudly.
106-
if git push; then
114+
if [ -z "$(git tag --points-at HEAD)" ]; then
115+
echo "Nothing was published; the version bump stays local and the changesets are intact for a re-dispatch."
107116
exit 0
108117
fi
109118
110-
git fetch origin "${GITHUB_REF_NAME}"
111-
if git diff --name-only "HEAD~1" FETCH_HEAD -- packages/ | grep -q .; then
112-
echo "::error::'${GITHUB_REF_NAME}' advanced with changes under packages/ during the release. Refusing to rebase the version bump onto unpublished package code (the source would diverge from the published artifacts). Reconcile manually."
113-
exit 1
119+
if ! git push; then
120+
# A PR merged mid-release. Merge rather than rebase: the tags already
121+
# point at this commit and rewriting it would strand them off the branch.
122+
git fetch origin "${GITHUB_REF_NAME}"
123+
git merge --no-edit FETCH_HEAD
124+
git push
114125
fi
115-
116-
git pull --rebase origin "${GITHUB_REF_NAME}"
117-
git push
118126
env:
119127
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"cli-highlight": "^2.1.11",
8282
"commander": "^11.1.0",
8383
"console-table-printer": "^2.11.2",
84-
"e2b": "^2.36.1",
84+
"e2b": "workspace:^",
8585
"handlebars": "^4.7.9",
8686
"inquirer": "^12.10.0",
8787
"simple-update-notifier": "^2.0.0",

packages/cli/vitest.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export default defineConfig({
1313
resolve: {
1414
alias: {
1515
src: path.resolve(__dirname, './src'),
16+
// Mirror the `e2b` path in tsconfig.json, which is what tsc and the bundle
17+
// already resolve to. Without it `e2b` resolves to the workspace package's
18+
// `main`, which only exists once the SDK has been built.
19+
e2b: path.resolve(__dirname, '../js-sdk/src'),
1620
},
1721
},
1822
})

pnpm-lock.yaml

Lines changed: 2 additions & 22 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)