chore: switch to tag-based release workflow with per-tier npm dist-tags (#996)

- create-release.yml now triggers on v* tag push (not PR merge).
  The release branch is never merged to main; publishing is triggered
  by pushing the tag directly to the rel-X.Y.Z branch.
- npm-publish.yml resolves the dist-tag from the version string:
    alpha → alpha, beta → beta, rc → rc, stable → latest
  Removes the temporary 'always publish as latest' workaround.
- release.md skill and AGENTS.md updated to document the new model.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-06-01 12:44:05 -06:00
committed by GitHub
co-authored by openhands
parent 14b1b1e8ad
commit 1d4fd1525c
4 changed files with 154 additions and 204 deletions
+100 -148
View File
@@ -1,6 +1,6 @@
---
name: release
description: Guide the release process for @openhands/agent-canvas — version bump PR, E2E validation, merge with automatic tagging, and downstream npm/Docker publishing.
description: Guide the release process for @openhands/agent-canvas — version bump on the release branch, QA, then tag to publish to npm and Docker.
triggers:
- release
- new release
@@ -11,191 +11,143 @@ triggers:
# Release Process for @openhands/agent-canvas
You are guiding a release of the `@openhands/agent-canvas` package. Follow these steps **in order**. Do NOT skip ahead — each step has a checkpoint where you must wait for the user.
## Overview
## Step 1: Check Current Version and Ask the User
Releases use a **long-lived release branch** model:
**IMPORTANT: You MUST complete this step and get explicit user confirmation before doing anything else.**
1. A `rel-X.Y.Z` branch is created from `main` at the start of a release cycle.
2. QA and fixes land on the branch (cherry-picked from main or landed directly).
3. When ready, a tag (`vX.Y.Z-rc.1`, `vX.Y.Z`, etc.) is pushed to the branch.
4. The tag push triggers all downstream workflows automatically.
5. **The release branch is never merged back to main.**
First, read the current version from `package.json`:
npm dist-tags by version tier:
| Version | Example | npm dist-tag | `npm install` resolves? |
|---|---|---|---|
| Alpha | `1.0.0-alpha.1` | `alpha` | `@alpha` only |
| Beta | `1.0.0-beta.1` | `beta` | `@beta` only |
| RC | `1.0.0-rc.1` | `rc` | `@rc` only |
| Stable | `1.0.0` | `latest` | ✅ default |
---
## Step 1: Confirm the Release Branch Exists
The branch must be named `rel-X.Y.Z` (e.g. `rel-1.0.0`). Check:
```bash
git branch -r | grep rel-
```
If it doesn't exist yet, create it from main:
```bash
git checkout main && git pull origin main
git checkout -b rel-<X.Y.Z>
git push -u origin rel-<X.Y.Z>
```
**STOP HERE if the branch doesn't exist.** Ask the user to confirm the release series (e.g. `1.0.0`) before creating it.
---
## Step 2: Ensure `package.json` Version Is Set
The version in `package.json` must match the tag you're about to push.
Check the current version:
```bash
node -p "require('./package.json').version"
```
Then present the result to the user and suggest the next logical version. Use these rules to form your suggestion:
- If the current version is a pre-release like `1.0.0-alpha.7`, suggest `1.0.0-alpha.8` (bump the last numeric segment).
- If the current version is stable like `1.2.3`, suggest `1.2.4` (patch bump) but mention they can also do `1.3.0` (minor) or `2.0.0` (major).
**Version format**: This project uses semver with optional pre-release suffixes.
- Pre-release examples: `1.0.0-alpha.8`, `1.0.0-beta.1`, `1.0.0-rc.1`
- Stable examples: `1.0.0`, `1.1.0`, `2.0.0`
**STOP HERE.** Tell the user the current version, your suggested next version, and ask:
> The current version is `<current>`. I'd suggest bumping to `<suggested>`. What version would you like to release?
**Do not proceed to Step 2 until the user confirms a version.**
## Step 2: Create the Release PR
### 2a. Create the release branch
The branch **must** be named `rel-<version>` (e.g., `rel-1.0.0-alpha.8`). This naming convention is required — the `create-release.yml` workflow detects merged release PRs by matching the `rel-` branch prefix.
If it needs updating (e.g. bumping from `1.0.0-alpha.8` to `1.0.0-rc.1`), update both `package.json` and `package-lock.json`:
```bash
git checkout main
git pull origin main
git checkout -b rel-<version>
git checkout rel-<X.Y.Z>
git pull origin rel-<X.Y.Z>
npm version <new-version> --no-git-tag-version
git add package.json package-lock.json
git commit -m "chore: bump version to <new-version>"
git push
```
### 2b. Bump the version
Update the version in **both** `package.json` and `package-lock.json`:
**Also update version references in `README.md`** if the Docker image tag examples reference a specific version:
```bash
npm version <version> --no-git-tag-version
sed -i 's/ghcr.io\/openhands\/agent-canvas:[0-9]*\.[0-9]*\.[0-9]*[^ ]*/ghcr.io\/openhands\/agent-canvas:<new-version>/g' README.md
git add README.md && git commit -m "docs: update README version to <new-version>" && git push
```
This updates both files without creating a git tag (the tag is created automatically on merge).
---
### 2c. Update version references in README.md
## Step 3: Push the Tag
The `README.md` contains Docker image tags that reference a specific version (e.g., `ghcr.io/openhands/agent-canvas:<version>`). Update **all** version references in `README.md` to match the new release version:
Confirm the branch is in the right state (CI green, QA done), then push the tag:
```bash
# Find and replace the old version tag with the new one
sed -i 's/ghcr.io\/openhands\/agent-canvas:[0-9]*\.[0-9]*\.[0-9]*[^ ]*/ghcr.io\/openhands\/agent-canvas:<version>/g' README.md
git checkout rel-<X.Y.Z>
git pull origin rel-<X.Y.Z>
git tag v<version>
git push origin v<version>
```
Verify the change:
Examples:
- First release candidate: `git tag v1.0.0-rc.1 && git push origin v1.0.0-rc.1`
- Subsequent RC: `git tag v1.0.0-rc.2 && git push origin v1.0.0-rc.2`
- Full release: `git tag v1.0.0 && git push origin v1.0.0`
```bash
git diff --stat
# Should show: package.json, package-lock.json, and README.md changed
```
### 2d. Commit and push
```bash
git add package.json package-lock.json README.md
git commit -m "chore: bump version to <version>"
git push -u origin rel-<version>
```
### 2e. Create the PR
Create the PR targeting `main` with the `e2e-tests` label:
```bash
gh pr create \
--title "chore: bump version to <version>" \
--body "## Release v<version>
This PR bumps the version to **<version>** for release.
### Release Checklist
- [x] Version bumped in package.json and package-lock.json
- [x] Version references updated in README.md
- [ ] CI passes (lint, test, build)
- [ ] Visual snapshot tests pass
- [ ] Mock-LLM E2E tests pass (triggered by \`e2e-tests\` label)
- [ ] Review and approve
### What happens on merge
When this PR is merged, the \`create-release.yml\` workflow will automatically:
1. Create a GitHub release with tag \`v<version>\` and auto-generated notes
2. The tag push triggers \`npm-publish.yml\` to publish to npm
3. The tag push triggers \`docker.yml\` to build and push Docker images to GHCR" \
--base main \
--head "rel-<version>" \
--label "e2e-tests"
```
## Step 3: Wait for CI and E2E Tests
The following checks must pass before merging:
| Workflow | Trigger | What it checks |
|---|---|---|
| **CI** (`ci.yml`) | Every PR | Lint, unit tests, app build, library build |
| **Snapshot Tests** (`snapshot-tests.yml`) | Every PR | Visual regression screenshots |
| **Mock-LLM E2E Tests** (`mock-llm-e2e.yml`) | `e2e-tests` label | End-to-end tests with a mock LLM against a real agent-server |
Monitor the PR checks:
```bash
gh pr checks <pr-number> --watch
```
If the mock-LLM E2E tests fail, investigate the failure in the workflow artifacts. The `e2e-tests` label can be removed and re-added to re-trigger the workflow.
If snapshot tests show intentional changes (e.g., version string in the UI changed), add the `update-snapshots` label to acknowledge the changes.
## Step 4: Merge the PR
Once all checks pass and the PR is approved, merge it:
```bash
gh pr merge <pr-number> --squash --delete-branch
```
### Automatic tagging on merge
The `create-release.yml` workflow automatically runs when a PR from a `rel-*` branch is merged into `main`. It will:
1. **Extract the version** from the branch name (e.g., `rel-1.0.0-alpha.8` → `1.0.0-alpha.8`)
2. **Create a GitHub release** with tag `v<version>` targeting the merge commit
3. **Auto-generate release notes** from the commits since the previous release
4. **Mark pre-release versions** (those containing a hyphen) as pre-releases
You do **not** need to manually create a tag or GitHub release.
### Downstream workflows triggered by the tag
The tag push (`v*`) automatically triggers:
**The tag push is the release trigger.** Three workflows fire in parallel:
| Workflow | What it does |
|---|---|
| **npm-publish.yml** | Builds and publishes `@openhands/agent-canvas` to npm with provenance |
| **docker.yml** | Builds and pushes multi-arch Docker images to `ghcr.io/openhands/agent-canvas` |
| `create-release.yml` | Creates the GitHub Release object with auto-generated notes |
| `npm-publish.yml` | Builds and publishes to npm with the correct dist-tag |
| `docker.yml` | Builds and pushes multi-arch Docker images to GHCR |
### Verify the release
---
After merging, verify the downstream workflows complete successfully:
## Step 4: Verify the Release
```bash
# Check the GitHub release was created
# GitHub release
gh release view v<version>
# Watch downstream workflow runs
gh run list --workflow=npm-publish.yml --limit=1
gh run list --workflow=docker.yml --limit=1
# npm (allow ~2 min for publish to propagate)
npm view @openhands/agent-canvas@<version>
npm view @openhands/agent-canvas dist-tags # confirm correct dist-tag
# Docker
docker pull ghcr.io/openhands/agent-canvas:<version>
```
Confirm the package is available:
- **npm**: `npm view @openhands/agent-canvas@<version>`
- **Docker**: `docker pull ghcr.io/openhands/agent-canvas:<version>`
Monitor workflow runs:
```bash
gh run list --workflow=npm-publish.yml --limit=3
gh run list --workflow=docker.yml --limit=3
```
---
## Troubleshooting
### E2E tests not triggering
The `e2e-tests` label must be present on the PR. If you added it but tests didn't run, remove and re-add the label, or manually trigger the workflow from the Actions tab.
### Tag already exists
If a tag `v<version>` already exists (e.g., from a previous failed attempt), the `create-release.yml` workflow will skip creation. Delete the existing release and tag first:
### package.json version doesn't match the tag
`npm-publish.yml` validates that `package.json` version equals the tag version and fails if they differ. Fix the version on the branch, push, then delete and re-push the tag:
```bash
gh release delete v<version> --yes
git push origin :refs/tags/v<version>
git push origin :refs/tags/v<version> # delete remote tag
git tag -d v<version> # delete local tag
# fix package.json, commit, push
git tag v<version> && git push origin v<version>
```
### npm publish failed
The `npm-publish.yml` workflow validates that `package.json` version matches the tag version. If they don't match, the publish will fail. Ensure the version bump in Step 2b matches the branch name exactly.
### GitHub release already exists
`create-release.yml` skips silently if the release already exists. To recreate it:
```bash
gh release delete v<version> --yes
```
Then the workflow will re-create it on the next tag push (or run it manually from the Actions tab).
## Reference
This release process is modeled after the [OpenHands/software-agent-sdk release workflow](https://github.com/OpenHands/software-agent-sdk/blob/main/.github/workflows/README-RELEASE.md) for consistency across OpenHands projects. Key differences:
- agent-canvas uses npm (not PyPI) for package publishing
- agent-canvas uses `npm version` (not `make set-package-version`) for version bumping
- agent-canvas release branches use `rel-<version>` naming (same as SDK)
- Both repos use `create-release.yml` to auto-create GitHub releases on merge of `rel-*` PRs
### npm publish failed mid-way
Check the `npm-publish.yml` run logs. The dist-tag is resolved from the `package.json` version — if the version string contains `alpha`, `beta`, or `rc`, it uses the matching dist-tag; otherwise `latest`.
+28 -41
View File
@@ -1,54 +1,46 @@
---
name: Create GitHub Release
# Automatically create a GitHub release when a release PR is merged into main.
# This bridges the gap between merging the release PR and the downstream
# workflows (npm-publish, docker) which trigger on tag push (v*).
#
# Modeled after OpenHands/software-agent-sdk's create-release.yml for consistency.
# Automatically create a GitHub release object when a v* tag is pushed.
# The tag is pushed manually to a rel-X.Y.Z release branch (never merged to main).
# This workflow creates the GitHub Release; the same tag push also triggers
# npm-publish.yml and docker.yml in parallel.
on:
pull_request:
types: [closed]
branches: [main]
push:
tags:
- 'v*'
jobs:
create-release:
# Only run when a release PR is merged (not just closed)
# and the branch follows the rel-X.Y.Z naming convention.
if: >
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'rel-')
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: read
steps:
- name: Extract version from branch name
- name: Extract version from tag
id: version
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
BRANCH="$PR_HEAD_REF"
VERSION="${BRANCH#rel-}"
TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"
# Accept semver with optional pre-release suffix (e.g. 1.0.0, 1.0.0-alpha.8)
# Accept semver with optional pre-release suffix (e.g. 1.0.0, 1.0.0-rc.1)
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "❌ Could not extract valid version from branch: $BRANCH"
echo "❌ Could not extract valid version from tag: $TAG"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "📦 Version: $VERSION"
- name: Check release does not already exist
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
run: |
if gh release view "v${VERSION}" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "⚠️ Release v${VERSION} already exists, skipping"
if gh release view "$TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "⚠️ Release $TAG already exists, skipping"
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
@@ -69,12 +61,9 @@ jobs:
- name: Create GitHub Release
if: steps.check.outputs.exists == 'false'
env:
# Use a PAT (not GITHUB_TOKEN) so the resulting tag-push event
# propagates to downstream workflows (npm-publish.yml, docker.yml).
# Tags created via GITHUB_TOKEN are silently suppressed by GitHub
# and will not trigger other workflow runs.
GH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
PREV_TAG: ${{ steps.prev_tag.outputs.prev_tag }}
run: |
NOTES_START_FLAG=()
@@ -82,34 +71,32 @@ jobs:
NOTES_START_FLAG=(--notes-start-tag "$PREV_TAG")
fi
# Detect pre-release versions (anything with a hyphen, e.g. 1.0.0-alpha.8)
# Detect pre-release versions (anything with a hyphen, e.g. 1.0.0-rc.1)
PRERELEASE_FLAG=()
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG=(--prerelease)
fi
gh release create "v${VERSION}" \
gh release create "$TAG" \
--repo "${{ github.repository }}" \
--target "${{ github.event.pull_request.merge_commit_sha }}" \
--title "v${VERSION}" \
--title "$TAG" \
--generate-notes \
"${NOTES_START_FLAG[@]}" \
"${PRERELEASE_FLAG[@]}"
echo "✅ Release v${VERSION} created!"
echo "🔗 https://github.com/${{ github.repository }}/releases/tag/v${VERSION}"
echo "✅ Release $TAG created!"
echo "🔗 https://github.com/${{ github.repository }}/releases/tag/$TAG"
- name: Summary
if: steps.check.outputs.exists == 'false'
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
run: |
echo "## ✅ Release v${VERSION} Created" >> "$GITHUB_STEP_SUMMARY"
echo "## ✅ Release $TAG Created" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tag**: v${VERSION}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Release**: https://github.com/${{ github.repository }}/releases/tag/v${VERSION}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tag**: $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "- **Release**: https://github.com/${{ github.repository }}/releases/tag/$TAG" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Triggered Downstream Workflows" >> "$GITHUB_STEP_SUMMARY"
echo "The tag push \`v${VERSION}\` will automatically trigger:" >> "$GITHUB_STEP_SUMMARY"
echo "Downstream workflows triggered by the same tag push:" >> "$GITHUB_STEP_SUMMARY"
echo "- **npm-publish.yml** — publish \`@openhands/agent-canvas\` to npm" >> "$GITHUB_STEP_SUMMARY"
echo "- **docker.yml** — build and push \`ghcr.io/openhands/agent-canvas\` Docker images" >> "$GITHUB_STEP_SUMMARY"
+25 -14
View File
@@ -90,18 +90,29 @@ jobs:
fi
echo "✓ Version $PACKAGE_VERSION matches release tag"
# Temporary: publish all versions (including prerelease) as "latest"
# until the first stable release (see cleanup issue #395).
# Note: OIDC trusted-publishing tokens cover only the `npm publish`
# call itself; a separate `npm dist-tag add` after publish fails
# with E401, so we publish directly with --tag latest in one step.
#
# Named prerelease dist-tags (alpha/beta/rc) are intentionally NOT
# published under this policy — only "latest" is used. This means
# `npm install @openhands/agent-canvas@alpha` will not resolve to
# any published version. When prerelease dist-tags need to be
# re-introduced (issue #395 cleanup), update this step to publish
# with a versioned dist-tag (e.g. --tag alpha) and only tag stable
# releases as "latest".
# Resolve the npm dist-tag from the version's pre-release identifier:
# alpha → --tag alpha (e.g. 1.0.0-alpha.1)
# beta → --tag beta (e.g. 1.0.0-beta.1)
# rc → --tag rc (e.g. 1.0.0-rc.1)
# stable → --tag latest (e.g. 1.0.0)
# Note: OIDC trusted-publishing tokens cover only the `npm publish` call
# itself; a separate `npm dist-tag add` would fail with E401, so the tag
# is resolved and passed directly in one step.
- name: Resolve npm dist-tag
id: dist_tag
run: |
VERSION=$(node -p "require('./package.json').version")
if [[ "$VERSION" == *-alpha* ]]; then
DIST_TAG="alpha"
elif [[ "$VERSION" == *-beta* ]]; then
DIST_TAG="beta"
elif [[ "$VERSION" == *-rc* ]]; then
DIST_TAG="rc"
else
DIST_TAG="latest"
fi
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
echo "📦 Version $VERSION → dist-tag: $DIST_TAG"
- name: Publish to npm with provenance
run: npm publish --access public --provenance --tag latest
run: npm publish --access public --provenance --tag ${{ steps.dist_tag.outputs.dist_tag }}
+1 -1
View File
@@ -552,6 +552,6 @@ When adding code that needs a new string, decide up front which rule it falls un
- Spec files live under `specs/`. Spec IDs are stable — never renumber. Mark deprecated specs with ~~strikethrough~~. Tag implementation code and tests with `// @spec BM-002 — Short title` comments so specs are grep-able across the codebase (`grep -rn '@spec BM-' src/ __tests__/`). Place the comment on the line immediately above the relevant code block or test. When multiple tests cover the same spec, use `it.each` if the test structure is identical.
- Release automation: `.github/workflows/create-release.yml` automatically creates a GitHub release (with tag `v<version>`) when a PR from a `rel-*` branch is merged into `main`. This is modeled after the SDK's `create-release.yml`. The tag push then triggers `npm-publish.yml` and `docker.yml` downstream. The release skill (`.agents/skills/release.md`, keyword trigger: `release`) guides agents through the full process: version bump PR, `e2e-tests` label for mock-LLM E2E validation, merge with auto-tagging. Pre-release versions (containing a hyphen) are marked as pre-releases on the GitHub release.
- Release automation: releases use a **long-lived release branch** model — a `rel-X.Y.Z` branch is created from `main`, QA/fixes land there, and publishing is triggered by pushing a `v*` tag directly to that branch (the branch is never merged back to main). Tag format determines the npm dist-tag: `alpha` → `alpha`, `beta` → `beta`, `rc` → `rc`, no suffix → `latest`. Three workflows fire in parallel on every `v*` tag push: `create-release.yml` (creates the GitHub Release object with auto-generated notes and marks pre-release for hyphenated versions), `npm-publish.yml` (builds and publishes to npm with the correct dist-tag), and `docker.yml` (builds multi-arch Docker images). The release skill (`.agents/skills/release.md`, keyword trigger: `release`) guides agents through the full process.
- Cloud conversation resume gating: when a cloud conversation is closed from the UI (`pauseCloudSandbox` is called), the conversation's `conversation_url` is NOT cleared -- it still points to the old sandbox host. `WebSocketProviderWrapper` must suppress the URL (pass `null` to `ConversationWebSocketProvider`) while `sandbox_status === "PAUSED"`, otherwise the WebSocket immediately tries the stale URL before the sandbox wakes. Symmetrically, `useActiveConversation`'s refetch interval must fast-poll (3 s) on both `!conversation_url` AND `sandbox_status === "PAUSED"` -- checking only the missing URL would leave the hook on the 30 s interval while the sandbox is resuming. The resume sequence: navigate -> sandbox PAUSED detected -> `resumeCloudSandbox` called (in `conversation.tsx`) -> fast-poll detects RUNNING -> `conversationUrl` unblocked -> WebSocket connects.