Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/scripts/build_release_itinerary.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env node

// This script formats the JSON written by `changeset status --output=<file>`
// as the package list that goes into the release's Slack notifications.

const path = require('path')

// Packages are listed in this order; anything not named here still shows up,
// under its workspace name, after the ones that are.
const labels = {
'@e2b/code-interpreter': 'JS SDK (@e2b/code-interpreter)',
'@e2b/code-interpreter-python': 'Python SDK (e2b-code-interpreter)',
'@e2b/data-extractor': 'Charts (e2b-charts)',
'@e2b/code-interpreter-template': 'Sandbox template (code-interpreter)',
}

const statusFile = process.argv[2]
if (!statusFile) {
console.error('Usage: build_release_itinerary.cjs <changeset-status.json>')
process.exit(1)
}

const order = Object.keys(labels)
const rank = (release) => {
const index = order.indexOf(release.name)
return index === -1 ? order.length : index
}

const { releases } = require(path.resolve(statusFile))
const lines = [...releases]
.sort((a, b) => rank(a) - rank(b))
.map(
(release) =>
`• ${labels[release.name] ?? release.name} v${release.newVersion}`
)

process.stdout.write(lines.join('\n') || '• No packages were published')
231 changes: 150 additions & 81 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
name: Release

on:
push:
branches:
- main
# Releases are cut by hand, as in e2b-dev/E2B: merging a changeset to main no
# longer publishes on its own, so changesets accumulate until someone
# dispatches this. A release that publishes nothing leaves them intact, which
# makes a re-dispatch the recovery path too.
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.

P2 Badge Restrict manual releases to main

Adding workflow_dispatch without a ref guard means this release workflow can be manually run from any branch selected in the UI/CLI; GitHub's manual-run docs describe both the branch dropdown and gh workflow run --ref. If a maintainer dispatches this on a feature branch that has changesets, the release job will still publish packages/tags and git push the version bump back to that branch instead of main, so the manual path should validate github.ref == 'refs/heads/main' before publishing.

Useful? React with 👍 / 👎.


concurrency: Release-${{ github.ref }}-foxtrot

Expand All @@ -12,12 +14,28 @@ permissions:
contents: write

jobs:
is_release:
name: Is release?
preflight:
name: Release preflight
runs-on: ubuntu-latest
outputs:
release: ${{ steps.version.outputs.release }}
js: ${{ steps.js.outputs.release }}
python: ${{ steps.python.outputs.release }}
charts: ${{ steps.charts.outputs.release }}
template: ${{ steps.template.outputs.release }}
itinerary: ${{ steps.itinerary.outputs.itinerary }}
steps:
- name: Check the ref
# `workflow_dispatch` offers every branch in the picker, and a feature
# branch carrying changesets would otherwise publish real packages and
# push the version bump to that branch.
if: github.ref != 'refs/heads/main'
env:
REF: ${{ github.ref }}
run: |
echo "::error::Releases must run on main; this run is on ${REF}."
exit 1

- name: Checkout Repo
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

Expand Down Expand Up @@ -56,77 +74,54 @@ jobs:
IS_RELEASE=$(./.github/scripts/is_release.sh)
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

changes:
name: Repository changes
needs: [is_release]
if: needs.is_release.outputs.release == 'true'
runs-on: ubuntu-latest
outputs:
js: ${{ steps.js.outputs.release }}
python: ${{ steps.python.outputs.release }}
charts: ${{ steps.charts.outputs.release }}
template: ${{ steps.template.outputs.release }}
steps:
- name: Checkout Repo
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Parse .tool-versions
uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1
with:
filename: '.tool-versions'
uppercase: 'true'
prefix: 'tool_version_'

- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
id: pnpm-install
with:
version: ${{ env.TOOL_VERSION_PNPM }}

- name: Setup Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: '${{ env.TOOL_VERSION_NODE }}'
registry-url: "https://registry.npmjs.org"
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Configure pnpm
run: |
pnpm config set auto-install-peers true
pnpm config set exclude-links-from-lockfile true

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Check JavasScript SDK Release
- name: Check JavaScript SDK Release
id: js
if: steps.version.outputs.release == 'true'
run: |
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/code-interpreter")
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

- name: Check Python SDK Release
id: python
if: steps.version.outputs.release == 'true'
run: |
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/code-interpreter-python")
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

- name: Check Charts SDK Release
id: charts
if: steps.version.outputs.release == 'true'
run: |
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/data-extractor")
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

- name: Check Template SDK Release
id: template
if: steps.version.outputs.release == 'true'
run: |
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/code-interpreter-template")
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"

- name: Build release itinerary
id: itinerary
if: steps.version.outputs.release == 'true'
# This only feeds the Slack notifications, so it must never be the thing
# that blocks a release; the messages fall back to a placeholder.
continue-on-error: true
run: |
pnpm changeset status --output=.cs-status.json
ITINERARY=$(node ./.github/scripts/build_release_itinerary.cjs .cs-status.json)
rm -f .cs-status.json
{
Comment on lines 104 to +115

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 'Build release itinerary' step embeds a ~12-line multi-line node -e script (label map, ordering, formatting) directly in release.yml, rather than following the repo's own convention of putting release logic in standalone files under .github/scripts/ (alongside is_release.sh and is_release_for_package.sh). Consider extracting it to something like .github/scripts/build_release_itinerary.cjs so it can be linted/unit-tested and any syntax error surfaces before an actual release run.

Extended reasoning...

The Build release itinerary step (release.yml lines ~92-116) builds the Slack release itinerary with an inline node -e block: it reads .cs-status.json (written by pnpm changeset status --output=...), maps package names to human-readable labels, orders them, formats each as a bullet line, and falls back to a "No packages were published" message. This is meaningfully more logic than the other steps in this job — none of which embed multi-line JS — and it is only exercised on an actual release run against real changeset data.

The repo already has an established pattern for this kind of thing: .github/scripts/is_release.sh and .github/scripts/is_release_for_package.sh are standalone, executable scripts invoked from adjacent steps in the very same job. Those scripts can be shellchecked and run locally against fixture data; the inline node -e block cannot be linted, cannot be unit-tested, and any typo or logic bug (e.g. a broken template-string interpolation, a bad property access on data.releases) will only be discovered when a real release runs — at which point the failure blocks the Slack notification step (and, depending on ordering, could fail the whole preflight job) rather than being caught in CI or code review.

Concretely, imagine a future edit adds a fifth package to the labels map but a stray comma or bracket is introduced. YAML itself would still parse fine — the script is just a string value to the run: block — so no workflow-level validation catches it. The mistake would only surface the next time a release actually runs, when node -e throws a SyntaxError and the itinerary step fails, which is the worst possible moment to discover it (mid-release, without a fast local repro path).

Extracting the transform to .github/scripts/build_release_itinerary.cjs (reading .cs-status.json from cwd or an argv path, same as the shell scripts take package names as argv) would let it be run and tested locally with mock changeset-status JSON, matches the existing convention in this same job, and does not change behavior — the workflow step would just become node .github/scripts/build_release_itinerary.cjs.

This is a maintainability nit, not a correctness bug — the current inline script is not wrong, just harder to test and lint than the surrounding scripts in the same job. It sits on a non-critical path (a Slack notification), so it should never block merging this PR.

echo "itinerary<<EOF"
echo "$ITINERARY"
echo "EOF"
} >> "$GITHUB_OUTPUT"

charts-release:
name: Charts release
needs: [changes]
if: needs.changes.outputs.charts == 'true'
needs: [preflight]
if: needs.preflight.outputs.charts == 'true'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.output_version.outputs.version }}
Expand Down Expand Up @@ -187,16 +182,15 @@ jobs:
id: output_version
working-directory: ./chart_data_extractor
run: |
echo "::set-output name=version::$(pnpm pkg get version --workspaces=false | tr -d \\\")"
echo "version=$(pnpm pkg get version --workspaces=false | tr -d \\\")" >> "$GITHUB_OUTPUT"

build-docker-image:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [changes, charts-release]
if: always() &&
needs: [preflight, charts-release]
if: (!cancelled()) &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
(needs.changes.outputs.template == 'true' || needs.changes.outputs.charts == 'true')
(needs.preflight.outputs.template == 'true' || needs.preflight.outputs.charts == 'true')
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
Expand Down Expand Up @@ -248,11 +242,10 @@ jobs:
build-template:
name: Build E2B template
runs-on: ubuntu-latest
needs: [build-docker-image]
if: always() &&
needs: [preflight, build-docker-image]
if: (!cancelled()) &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
(needs.changes.outputs.template == 'true' || needs.changes.outputs.charts == 'true')
(needs.preflight.outputs.template == 'true' || needs.preflight.outputs.charts == 'true')
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
Expand Down Expand Up @@ -283,30 +276,30 @@ jobs:

python-tests:
name: Python Tests
needs: [changes, build-template]
if: always() &&
needs: [preflight, build-template]
if: (!cancelled()) &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
needs.changes.outputs.template == 'true'
needs.preflight.outputs.template == 'true'
uses: ./.github/workflows/python_tests.yml
secrets: inherit

js-tests:
name: JS Tests
needs: [changes, build-template]
if: always() &&
needs: [preflight, build-template]
if: (!cancelled()) &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
needs.changes.outputs.template == 'true'
needs.preflight.outputs.template == 'true'
uses: ./.github/workflows/js_tests.yml
secrets: inherit

release:
needs: [python-tests, js-tests]
if: always() &&
# Every upstream job is listed, not just the tests: a job that fails makes its
# dependents *skip*, and a skipped test job is not a failure — so gating on
# `needs.*.result` only works for the jobs this one depends on directly.
needs: [preflight, charts-release, build-docker-image, build-template, python-tests, js-tests]
if: (!cancelled()) &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
(needs.changes.outputs.js == 'true' || needs.changes.outputs.python == 'true' || needs.changes.outputs.charts == 'true' || needs.changes.outputs.template == 'true')
needs.preflight.outputs.release == 'true'
name: Release
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -346,6 +339,8 @@ jobs:
version: ${{ env.TOOL_VERSION_PNPM }}

- name: Setup Node.js 24
# Deliberately ahead of the `node` baseline in .tool-versions: npm 11 is
# what OIDC trusted publishing needs (see #259).
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "24.x"
Expand All @@ -365,6 +360,29 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit new versions
# Commit before publishing, because `changeset publish` tags whatever HEAD
# it publishes from — tagging afterwards is what left every tag on the
# commit before its own version bump (SDK-298). Nothing in the tree
# references the artifacts about to be uploaded — `changeset version`
# leaves `pnpm-lock.yaml` untouched here, since no workspace package
# depends on another — so the commit is already complete. It stays local
# until something is actually published, so a publish that uploads
# nothing leaves the branch untouched and the changesets intact for a
# re-run.
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# `add -A`, not `commit -a`: `changeset version` writes each package's
# CHANGELOG.md as a new file the first time, which `-a` would drop.
git add -A
if git diff --cached --quiet; then
echo "::error::'changeset version' produced no changes, so there is no version bump to publish or tag."
exit 1
fi
git commit -m "[skip ci] Release new versions"

- name: Release new versions
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
Expand All @@ -375,20 +393,52 @@ jobs:
NPM_TOKEN: "" # See https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}

- name: Update lock file
run: pnpm i --no-link --no-frozen-lockfile

- name: Commit new versions
- name: Push new versions
# Gate on the tags rather than on whether the publish step succeeded: they
# are what has to end up reachable. `changeset publish` tags at HEAD and the
# step above pushes them to origin, so if any exist — even from a publish
# that then failed partway — the commit they point at has to land, or they
# hang off no branch, which is the SDK-298 breakage this change prevents.
if: always()
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -am "[skip ci] Release new versions" || exit 0
git push
if [ -z "$(git tag --points-at HEAD)" ]; then
echo "Nothing was published; the version bump stays local and the changesets are intact for a re-run."
exit 0
fi

if ! git push; then
# A PR merged mid-release. Merge rather than rebase: the tags already
# point at this commit and rewriting it would strand them off the branch.
git fetch origin "${GITHUB_REF_NAME}"
git merge --no-edit FETCH_HEAD
git push
fi
Comment thread
cursor[bot] marked this conversation as resolved.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

report-start:
needs: [preflight]
if: needs.preflight.outputs.release == 'true'
name: Code Interpreter Release Started - Slack Notification
runs-on: ubuntu-latest
steps:
- name: Release Started - Slack Notification
uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0
env:
SLACK_COLOR: "#3aa3e3"
SLACK_MESSAGE: |
:rocket: A new release has been triggered :hourglass_flowing_sand:

*Releasing:*
${{ needs.preflight.outputs.itinerary || '• (itinerary unavailable)' }}
SLACK_TITLE: Code Interpreter Release Started
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_CHANNEL: "monitoring-releases"

report-failure:
needs: [python-tests, js-tests, release]
# `preflight` included so a failure there is reported too, whether or not
# `failure()` looks past this job's direct dependencies.
needs: [preflight, charts-release, build-docker-image, build-template, python-tests, js-tests, release]
if: failure()
name: Code Interpreter Release Failed - Slack Notification
runs-on: ubuntu-latest
Expand All @@ -401,3 +451,22 @@ jobs:
SLACK_TITLE: Code Interpreter Release Failed
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_CHANNEL: "monitoring-releases"

report-success:
needs: [preflight, release]
if: (!cancelled()) && needs.release.result == 'success'
name: Code Interpreter Release Succeeded - Slack Notification
runs-on: ubuntu-latest
steps:
- name: Release Succeeded - Slack Notification
uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0
env:
SLACK_COLOR: "#36a64f"
SLACK_MESSAGE: |
:tada: A new version has been released successfully! :ship-it-parrot:

*Released:*
${{ needs.preflight.outputs.itinerary || '• (itinerary unavailable)' }}
SLACK_TITLE: Code Interpreter Release Succeeded
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_CHANNEL: "monitoring-releases"
Loading