Skip to content

Publish Release

Publish Release #16

name: Publish Release
on:
workflow_dispatch:
inputs:
release_type:
description: "Release type"
required: true
type: choice
options:
- stable
- rc
- beta
default: stable
pre_release_number:
description: "Pre-release number (for rc/beta, leave empty for auto-increment)"
required: false
type: string
dry_run:
description: "Dry run (skip actual publish)"
required: false
type: boolean
default: false
permissions:
contents: write
packages: write
id-token: write
concurrency:
group: publish-release-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.version.outputs.version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
branch: ${{ steps.context.outputs.branch }}
env:
NX_DAEMON: "false"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate branch
id: context
shell: bash
run: |
set -euo pipefail
# Get current branch
BRANCH="${GITHUB_REF#refs/heads/}"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
# Validate branch is a release branch
if [[ ! "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.x$ ]]; then
echo "::error::This workflow must be run from a release/X.Y.x branch. Current branch: $BRANCH"
exit 1
fi
# Extract release line (X.Y) from release/X.Y.x
RELEASE_LINE=$(echo "$BRANCH" | sed 's/release\/\([0-9]*\.[0-9]*\).*/\1/')
echo "release_line=$RELEASE_LINE" >> "$GITHUB_OUTPUT"
echo "Branch: $BRANCH"
echo "Release line: $RELEASE_LINE"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "yarn"
registry-url: "https://registry.npmjs.org/"
- name: Update npm CLI for trusted publishing
run: npm install -g npm@latest
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Compute version
id: version
shell: bash
run: |
set -euo pipefail
RELEASE_LINE="${{ steps.context.outputs.release_line }}"
RELEASE_TYPE="${{ inputs.release_type }}"
PRE_RELEASE_NUM="${{ inputs.pre_release_number }}"
# Fetch all tags
git fetch --tags
# Use compute-next-patch script
if [ -n "$PRE_RELEASE_NUM" ]; then
VERSION=$(node scripts/compute-next-patch.mjs "$RELEASE_LINE" "$RELEASE_TYPE" "$PRE_RELEASE_NUM")
else
VERSION=$(node scripts/compute-next-patch.mjs "$RELEASE_LINE" "$RELEASE_TYPE")
fi
# Determine if this is a pre-release
IS_PRERELEASE="false"
NPM_TAG="latest"
if [[ "$VERSION" == *"-rc."* ]]; then
IS_PRERELEASE="true"
NPM_TAG="rc"
elif [[ "$VERSION" == *"-beta."* ]]; then
IS_PRERELEASE="true"
NPM_TAG="beta"
fi
# Check if unified tag already exists
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "::error::Tag v$VERSION already exists!"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_type=$RELEASE_TYPE" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
echo "npm_tag=$NPM_TAG" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
echo "Release type: $RELEASE_TYPE"
echo "Is prerelease: $IS_PRERELEASE"
echo "NPM tag: $NPM_TAG"
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Get previous version
id: prev_version
run: |
RELEASE_LINE="${{ steps.context.outputs.release_line }}"
# Get the latest stable tag for this release line
PREV_TAG=$(git tag --list "v${RELEASE_LINE}.*" --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
if [ -z "$PREV_TAG" ]; then
# No previous tag in this line, try previous minor
PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
fi
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
echo "Previous tag: $PREV_TAG"
- name: Generate diff
id: diff
run: |
PREV_TAG="${{ steps.prev_version.outputs.prev_tag }}"
if [ -n "$PREV_TAG" ]; then
DIFF=$(git diff "$PREV_TAG"..HEAD \
--stat --patch \
-- '*.ts' '*.js' '*.json' ':!package-lock.json' ':!*.test.ts' ':!*.spec.ts' \
| head -c 50000)
else
DIFF="Initial release - no previous version to compare"
fi
# Use file to avoid shell escaping issues
echo "$DIFF" > /tmp/diff.txt
- name: Generate AI changelog
id: ai_changelog
if: ${{ inputs.dry_run != true && inputs.release_type == 'stable' }}
continue-on-error: true
uses: actions/github-script@v7
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
VERSION: v${{ steps.version.outputs.version }}
VERSION_MINOR: ${{ steps.context.outputs.release_line }}
with:
script: |
const fs = require('fs');
// Skip if API key is missing
if (!process.env.OPENAI_API_KEY) {
core.warning('OPENAI_API_KEY missing; skipping AI changelog');
core.setOutput('changelog', '');
core.setOutput('has_card_mdx', 'false');
return;
}
try {
const diff = fs.readFileSync('/tmp/diff.txt', 'utf8');
const releaseDate = new Date().toISOString().split('T')[0];
const version = process.env.VERSION;
const versionNum = version.replace('v', '');
const prompt = `You are a technical writer for Enclave, a production-ready JavaScript sandbox for AI agent code execution.
The Enclave ecosystem includes:
- @enclave-vm/ast: AST security guard with CVE protection
- @enclave-vm/core: Secure AgentScript execution environment
- @enclave-vm/types: Protocol types and Zod schemas
- @enclave-vm/stream: NDJSON streaming with encryption
- @enclave-vm/broker: Tool broker with session management
- @enclave-vm/client: Browser and Node.js client SDK
- @enclave-vm/react: React hooks and components
- @enclave-vm/runtime: Standalone deployable runtime
Version: ${version}
Release Date: ${releaseDate}
Git diff:
\`\`\`
${diff.substring(0, 40000)}
\`\`\`
Generate two outputs:
1. CHANGELOG entry (Keep a Changelog format):
## [${versionNum}] - ${releaseDate}
### Added/Changed/Fixed/Security (only include relevant sections)
- Concise description of changes
2. A SINGLE Mintlify <Card> component (NOT the full file, just the Card):
<Card
title="Enclave ${version}: Brief title"
href="https://github.com/agentfront/enclave/releases/tag/${version}"
cta="View full changelog"
>
**Feature** – Description.
- Details if needed
</Card>
IMPORTANT: For cardMdx, output ONLY the <Card>...</Card> component, nothing else.
Output ONLY valid JSON: {"changelog": "...", "cardMdx": "<Card...>...</Card>"}`;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL || 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' }
})
});
if (!response.ok) throw new Error(`OpenAI API error: ${response.status}`);
const data = await response.json();
const result = JSON.parse(data.choices[0].message.content);
// Write card MDX to file to avoid shell escaping issues
fs.writeFileSync('/tmp/card-mdx.txt', result.cardMdx);
core.setOutput('changelog', result.changelog);
core.setOutput('has_card_mdx', result.cardMdx ? 'true' : 'false');
} catch (err) {
core.warning(`AI changelog skipped: ${err.message}`);
core.setOutput('changelog', '');
core.setOutput('has_card_mdx', 'false');
}
- name: Update package versions
if: ${{ inputs.dry_run != true }}
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Setting version $VERSION for all libs"
npx nx release version "$VERSION" --git-commit=false --git-tag=false
- name: Update demo app dependency versions
if: ${{ inputs.dry_run != true }}
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Updating @enclave-vm/* dependencies in apps to $VERSION"
for pkg in apps/*/package.json; do
[ -f "$pkg" ] || continue
# Update any @enclave-vm/* dependency versions
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg', 'utf8'));
let changed = false;
for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) {
if (!p[section]) continue;
for (const [name, ver] of Object.entries(p[section])) {
if (name.startsWith('@enclave-vm/') && ver !== '$VERSION') {
p[section][name] = '$VERSION';
changed = true;
}
}
}
if (changed) {
fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
console.log('Updated: $pkg');
} else {
console.log('No changes: $pkg');
}
"
done
- name: Commit version bump
if: ${{ inputs.dry_run != true }}
run: |
if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "chore(release): v${{ steps.version.outputs.version }}"
git push origin HEAD
fi
- name: Build packages
run: |
echo "Building all lib packages..."
yarn nx run-many --targets=build --projects='libs/*' --parallel
- name: Publish to npm
if: ${{ inputs.dry_run != true }}
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
NPM_TAG="${{ steps.version.outputs.npm_tag }}"
echo "Publishing all libs with tag $NPM_TAG..."
npx nx release publish --tag="$NPM_TAG"
echo "Successfully published version ${{ steps.version.outputs.version }}"
- name: Create and push git tag
if: ${{ inputs.dry_run != true }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="v$VERSION"
BRANCH="${{ steps.context.outputs.branch }}"
# Fetch latest to ensure we tag the committed version
git fetch origin "$BRANCH"
git checkout "$BRANCH"
git pull origin "$BRANCH"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
echo "Created and pushed tag: $TAG"
- name: Prepare release body
id: release_body
env:
CHANGELOG: ${{ steps.ai_changelog.outputs.changelog }}
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_TYPE="${{ steps.version.outputs.release_type }}"
RELEASE_LINE="${{ steps.context.outputs.release_line }}"
BRANCH="${{ steps.context.outputs.branch }}"
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
PROJECTS="core,types,stream,broker,client,react,runtime,ast"
# Start building the release body
{
echo "## Release v${VERSION}"
echo ""
echo "**Release type:** ${RELEASE_TYPE}"
echo "**Release line:** ${RELEASE_LINE}.x"
echo "**Branch:** ${BRANCH}"
echo ""
echo "### Published Packages"
echo ""
} > /tmp/release-body.md
# List published packages with npm links
IFS=',' read -ra LIBS <<< "$PROJECTS"
for lib in "${LIBS[@]}"; do
# Get npm package name from package.json
if [ -f "libs/$lib/package.json" ]; then
NPM_NAME=$(node -p "require('./libs/$lib/package.json').name")
echo "- [\`${NPM_NAME}@${VERSION}\`](https://www.npmjs.com/package/${NPM_NAME}/v/${VERSION})" >> /tmp/release-body.md
fi
done
# Add AI-generated changelog if available
if [ -f /tmp/card-mdx.txt ] && [ -s /tmp/card-mdx.txt ] && [ -n "$CHANGELOG" ]; then
echo "" >> /tmp/release-body.md
echo "$CHANGELOG" >> /tmp/release-body.md
fi
# Add pre-release note if applicable
if [ "$IS_PRERELEASE" = "true" ]; then
echo "" >> /tmp/release-body.md
echo "> **Note:** This is a pre-release version." >> /tmp/release-body.md
fi
# Add Card MDX as hidden comment for docs sync (only for stable releases)
# NOTE: Content is sanitized to prevent --> from breaking the HTML comment.
# Consumer must reverse: replace "--&gt;" with "-->" after extraction.
if [ -f /tmp/card-mdx.txt ] && [ -s /tmp/card-mdx.txt ]; then
echo "" >> /tmp/release-body.md
echo "<!--" >> /tmp/release-body.md
echo "CARD_MDX_START" >> /tmp/release-body.md
sed 's/-->/--\&gt;/g' /tmp/card-mdx.txt >> /tmp/release-body.md
echo "CARD_MDX_END" >> /tmp/release-body.md
echo "-->" >> /tmp/release-body.md
fi
- name: Create GitHub Release
if: ${{ inputs.dry_run != true }}
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
prerelease: ${{ steps.version.outputs.is_prerelease }}
generate_release_notes: false
body_path: /tmp/release-body.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger docs sync
if: ${{ inputs.dry_run != true && steps.version.outputs.is_prerelease == 'false' }}
continue-on-error: true
uses: actions/github-script@v7
env:
VERSION: v${{ steps.version.outputs.version }}
VERSION_MINOR: ${{ steps.context.outputs.release_line }}
with:
github-token: ${{ secrets.DOCS_SYNC_TOKEN }}
script: |
const tag = process.env.VERSION;
const versionMinor = process.env.VERSION_MINOR;
const sha = context.sha;
console.log(`Triggering docs sync for enclave`);
console.log(` Tag: ${tag}`);
console.log(` SHA: ${sha}`);
console.log(` Version minor: ${versionMinor}`);
try {
await github.rest.repos.createDispatchEvent({
owner: 'agentfront',
repo: 'docs',
event_type: 'sync-docs',
client_payload: {
repo: 'enclave',
sha: sha,
tag: tag,
version_minor: versionMinor
}
});
console.log(`Successfully triggered docs sync for ${tag}`);
} catch (error) {
console.error(`Failed to trigger docs sync: ${error.message}`);
// Don't fail the release for docs sync issues
}
- name: Summary
run: |
if [ "${{ inputs.dry_run }}" = "true" ]; then
echo "## Dry Run Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "> **This was a dry run. No packages were published.**" >> "$GITHUB_STEP_SUMMARY"
else
echo "## Release Complete" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Property | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Version | \`${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Tag | \`v${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Release type | ${{ steps.version.outputs.release_type }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| NPM tag | \`${{ steps.version.outputs.npm_tag }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Pre-release | ${{ steps.version.outputs.is_prerelease }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Branch | \`${{ steps.context.outputs.branch }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Packages | All libs/* |" >> "$GITHUB_STEP_SUMMARY"
cherry-pick-version-to-main:
needs: publish
if: >
inputs.dry_run != true &&
needs.publish.outputs.is_prerelease == 'false'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if latest semver
id: check
run: |
set -euo pipefail
VERSION="${{ needs.publish.outputs.version }}"
git fetch --tags
# Get all stable version tags, sort by semver, pick highest
LATEST=$(git tag --list 'v*' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -1 \
| sed 's/^v//')
echo "Released version: $VERSION"
echo "Latest stable tag: $LATEST"
if [ "$VERSION" = "$LATEST" ]; then
echo "is_latest=true" >> "$GITHUB_OUTPUT"
echo "This is the latest version — will cherry-pick to main"
else
echo "is_latest=false" >> "$GITHUB_OUTPUT"
echo "Skipping: v$VERSION is not the latest (v$LATEST is newer)"
fi
- name: Configure git
if: steps.check.outputs.is_latest == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Cherry-pick version bump to main
if: steps.check.outputs.is_latest == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ needs.publish.outputs.version }}"
RELEASE_BRANCH="${{ needs.publish.outputs.branch }}"
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
# Find the version bump commit on the release branch
VERSION_COMMIT=$(git log "origin/$RELEASE_BRANCH" \
--grep="chore(release): v${VERSION}" \
--format="%H" -1)
if [ -z "$VERSION_COMMIT" ]; then
echo "::warning::Could not find version bump commit for v${VERSION}"
exit 0
fi
echo "Found version bump commit: $VERSION_COMMIT"
git fetch origin "$DEFAULT_BRANCH"
# Skip if the version bump is already on the default branch
if git merge-base --is-ancestor "$VERSION_COMMIT" "origin/$DEFAULT_BRANCH"; then
echo "Version bump commit $VERSION_COMMIT is already on $DEFAULT_BRANCH — skipping cherry-pick"
exit 0
fi
# Prepare cherry-pick branch
CHERRY_BRANCH="cherry-pick/v${VERSION}-version-to-main"
git checkout "$DEFAULT_BRANCH"
git pull origin "$DEFAULT_BRANCH"
# Clean up existing remote branch if any
git push origin --delete "$CHERRY_BRANCH" 2>/dev/null || true
git checkout -b "$CHERRY_BRANCH"
# Attempt cherry-pick
if git cherry-pick "$VERSION_COMMIT" --no-commit; then
# Check if cherry-pick produced any changes (may be empty if already applied via a different commit)
if [ -z "$(git diff --cached --name-only)" ]; then
echo "Cherry-pick produced no changes — version bump already applied on $DEFAULT_BRANCH"
git reset HEAD 2>/dev/null || true
exit 0
fi
git commit -m "$(cat <<EOF
chore: sync version to $VERSION
Cherry-picked from $RELEASE_BRANCH (release v$VERSION)
Original commit: $VERSION_COMMIT
EOF
)"
git push origin "$CHERRY_BRANCH"
gh pr create \
--base "$DEFAULT_BRANCH" \
--head "$CHERRY_BRANCH" \
--title "chore: sync version to v${VERSION}" \
--label "cherry-pick" \
--label "auto-cherry-pick" \
--body "$(cat <<EOF
## Version sync to main
Updates all \`@enclave-vm/*\` package versions to \`${VERSION}\` on \`${DEFAULT_BRANCH}\`.
This cherry-pick was automatically created because \`v${VERSION}\` is the **latest stable release**.
**Source:** \`${RELEASE_BRANCH}\` release v${VERSION}
---
_Auto-generated by the publish-release workflow._
EOF
)"
echo "Cherry-pick PR created successfully"
else
# Check if failure is due to empty cherry-pick (already applied) vs actual conflicts
if [ -z "$(git status --porcelain)" ]; then
echo "Cherry-pick is empty — version bump already applied on $DEFAULT_BRANCH"
git cherry-pick --abort 2>/dev/null || true
exit 0
fi
git cherry-pick --abort || true
echo "::warning::Cherry-pick had conflicts. Creating issue for manual resolution."
gh issue create \
--title "Manual version sync needed: v${VERSION} to main" \
--label "cherry-pick" \
--label "conflict" \
--label "needs-attention" \
--body "$(cat <<EOF
## Manual Version Sync Required
Auto cherry-pick of version bump to \`v${VERSION}\` failed due to conflicts.
### Manual Steps
\`\`\`bash
git checkout $DEFAULT_BRANCH && git pull
git checkout -b cherry-pick/v${VERSION}-version-to-main
git cherry-pick $VERSION_COMMIT
# Resolve conflicts
git add . && git cherry-pick --continue
git push origin cherry-pick/v${VERSION}-version-to-main
gh pr create --base $DEFAULT_BRANCH --title "chore: sync version to v${VERSION}"
\`\`\`
---
_Auto-generated by the publish-release workflow._
EOF
)"
fi