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
182 changes: 156 additions & 26 deletions .github/workflows/release-sdk.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
# Manual SDK release workflow.
# Use this workflow to run SDK releases directly from GitHub Actions.
name: "\U0001F4E6 Release SDK (manual fallback)"
# Auto-releases SDK on push to main (@next channel).
# Stable releases are local-only (pnpm run release:local).
# Also supports manual dispatch as a fallback for one-off releases.
name: "\U0001F4E6 Release SDK"

on:
push:
branches:
- main
paths:
# Keep in sync with packages/sdk/.releaserc.cjs includePaths (patch-commit-filter).
- 'packages/sdk/**'
- 'apps/cli/**'
- 'packages/document-api/**'
- 'packages/superdoc/**'
- 'packages/super-editor/**'
- 'packages/layout-engine/**'
- 'packages/ai/**'
- 'packages/word-layout/**'
- 'packages/preset-geometry/**'
- 'scripts/semantic-release/**'
- '!**/*.md'
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g. 1.0.0-alpha.7). Leave empty to release the current version."
description: "Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version."
required: false
type: string
dry-run:
Expand All @@ -15,21 +32,151 @@ on:
type: boolean
default: false
npm-tag:
description: "npm dist-tag for Node SDK publish (e.g. latest, next)"
description: "npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override."
required: false
type: string
default: latest

permissions:
contents: read
contents: write
packages: write
id-token: write # PyPI trusted publishing (OIDC)

concurrency:
group: release-sdk
group: release-sdk-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
# -------------------------------------------------------------------
# Auto-release via semantic-release (push trigger)
# -------------------------------------------------------------------
auto-release:
if: github.event_name == 'push'
runs-on: ubuntu-24.04
environment: pypi
outputs:
released: ${{ steps.detect.outputs.released }}
version: ${{ steps.detect.outputs.version }}
steps:
- name: Generate token
id: generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: "https://registry.npmjs.org"

- uses: oven-sh/setup-bun@v2

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install canvas system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential libcairo2-dev libpango1.0-dev \
libjpeg-dev libgif-dev librsvg2-dev libpixman-1-dev

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

- name: Install Python build tools
run: pip install build

- name: Build packages
run: pnpm run build

- name: Snapshot tags before release
id: tags_before
run: echo "tags=$(git tag --list 'sdk-v*' | sort | tr '\n' ',')" >> "$GITHUB_OUTPUT"

- name: Run semantic-release
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }}
working-directory: packages/sdk
run: pnpx semantic-release

- name: Detect whether a release was created
id: detect
run: |
BEFORE="${{ steps.tags_before.outputs.tags }}"
AFTER=$(git tag --list 'sdk-v*' | sort | tr '\n' ',')
RELEASE_TAG=$(git tag --points-at HEAD --list 'sdk-v*' --sort=-version:refname | head -n 1)
if [ -z "$RELEASE_TAG" ]; then
echo "release_present=false" >> "$GITHUB_OUTPUT"
echo "released=false" >> "$GITHUB_OUTPUT"
echo "version=" >> "$GITHUB_OUTPUT"
echo "dist_tag=" >> "$GITHUB_OUTPUT"
echo "No SDK release tag at HEAD."
else
echo "release_present=true" >> "$GITHUB_OUTPUT"
if [ "$BEFORE" = "$AFTER" ]; then
echo "released=false" >> "$GITHUB_OUTPUT"
else
echo "released=true" >> "$GITHUB_OUTPUT"
fi
VERSION="${RELEASE_TAG#sdk-v}"
if [[ "$VERSION" == *-next.* ]]; then
DIST_TAG="next"
else
DIST_TAG="latest"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
if [ "$BEFORE" = "$AFTER" ]; then
echo "SDK release tag already present at HEAD: $RELEASE_TAG"
else
echo "Released SDK v$VERSION"
fi
fi

- name: Resume Node SDK publish for existing release tag
if: steps.detect.outputs.release_present == 'true' && steps.detect.outputs.released != 'true'
run: node packages/sdk/scripts/sdk-release-publish.mjs --tag "${{ steps.detect.outputs.dist_tag }}" --npm-only
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

# Build + publish Python whenever this commit corresponds to an SDK release
- name: Build and verify Python SDK
if: steps.detect.outputs.release_present == 'true'
run: node packages/sdk/scripts/build-python-sdk.mjs

- name: Publish companion Python packages to PyPI
if: steps.detect.outputs.release_present == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/sdk/langs/python/companion-dist/
skip-existing: true

- name: Publish main Python SDK to PyPI
if: steps.detect.outputs.release_present == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/sdk/langs/python/dist/
skip-existing: true

# -------------------------------------------------------------------
# Manual fallback (workflow_dispatch)
# -------------------------------------------------------------------
manual-release:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
environment: ${{ inputs.dry-run && '' || 'pypi' }}
steps:
Expand All @@ -55,13 +202,10 @@ jobs:
- name: Install Python build tools
run: pip install build

# ---------------------------------------------------------------
# Show current version (visible in the Actions run summary)
# ---------------------------------------------------------------
- name: Show current version
run: |
CURRENT=$(node -p "require('./packages/sdk/package.json').version")
echo "### SDK Release" >> $GITHUB_STEP_SUMMARY
echo "### SDK Release (manual)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| | Version |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
Expand All @@ -74,22 +218,13 @@ jobs:
echo "| **Dry run** | \`${{ inputs.dry-run }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **npm tag** | \`${{ inputs.npm-tag }}\` |" >> $GITHUB_STEP_SUMMARY

# ---------------------------------------------------------------
# Set version (if provided)
# ---------------------------------------------------------------
- name: Set version
if: inputs.version != ''
run: node packages/sdk/scripts/sync-sdk-version.mjs --set "${{ inputs.version }}"

# ---------------------------------------------------------------
# Generate + validate
# ---------------------------------------------------------------
- name: Generate all artifacts
run: pnpm run generate:all

# ---------------------------------------------------------------
# Node SDK
# ---------------------------------------------------------------
- name: Build Node SDK
run: pnpm --prefix packages/sdk/langs/node run build

Expand All @@ -114,21 +249,16 @@ jobs:
if: ${{ inputs.dry-run }}
run: node packages/sdk/scripts/sdk-release-publish.mjs --tag "${{ inputs.npm-tag }}" --npm-only --dry-run

# ---------------------------------------------------------------
# Python SDK (main + 5 companion packages)
# ---------------------------------------------------------------
- name: Build and verify Python SDK
run: node packages/sdk/scripts/build-python-sdk.mjs

# Publish companions first — root depends on them being on PyPI.
- name: Publish companion Python packages to PyPI
if: ${{ !inputs.dry-run }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/sdk/langs/python/companion-dist/
skip-existing: true

# Publish root last.
- name: Publish main Python SDK to PyPI
if: ${{ !inputs.dry-run }}
uses: pypa/gh-action-pypi-publish@release/v1
Expand Down
7 changes: 5 additions & 2 deletions cicd.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,16 @@ The workflow is `.github/workflows/release-cli.yml`. It analyzes commits across

| Command | What it does |
|---------|-------------|
| `pnpm run release:local` | Releases **superdoc then CLI** in sequence on `stable` |
| `pnpm run release:local` | Releases **superdoc CLI → SDK** in sequence on `stable` |
| `pnpm run release:local:superdoc` | Releases superdoc only |
| `pnpm run release:local:cli` | Releases CLI only |
| `pnpm run release:local:sdk` | Releases SDK only |

All accept `-- --dry-run` to preview without publishing. The combined orchestrator (`release:local`) enforces a `stable` branch guard (override with `--branch=<name>`).

`@semantic-release/git` automatically pushes version commits and tags when releasing on the `stable` branch. This is existing behavior for both superdoc and CLI.
`@semantic-release/git` automatically pushes version commits and tags when releasing on the `stable` branch. This is existing behavior for superdoc, CLI, and SDK.

SDK stable releases publish both npm (via `sdk-release-publish.mjs`) and PyPI (via `twine`). Prerequisites: `pip install twine` and `PYPI_PUBLISH_TOKEN` in your shell env.

### Raw Platform Publish (bypass semantic-release)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"release:local": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-stable.mjs",
"release:local:superdoc": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-superdoc.mjs",
"release:local:cli": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-cli.mjs",
"release:local:sdk": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-sdk.mjs",
"prepare": "if [ -z \"$CI\" ]; then lefthook install; fi",
"test:layout": "bun scripts/test-layout.mjs",
"test:visual": "bun scripts/test-visual.mjs",
Expand Down
23 changes: 16 additions & 7 deletions packages/sdk/.releaserc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,8 @@ const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;

const config = {
branches: [
// Semantic-release requires at least one non-prerelease release branch.
{ name: 'stable', channel: 'latest' },
// SDK auto-release runs from main and should publish alpha prereleases
// on the latest dist-tag (no next channel).
{ name: 'main', prerelease: 'alpha', channel: 'latest' },
{ name: 'main', prerelease: 'next', channel: 'next' },
],
tagFormat: 'sdk-v${version}',
plugins: [
Expand All @@ -46,9 +43,8 @@ const config = {
'pnpm --prefix langs/node run build',
'node scripts/sdk-validate.mjs',
].join(' && '),
// Publish: build artifacts + publish npm packages (PyPI handled by workflow)
publishCmd:
'node scripts/sdk-release-publish.mjs --tag ${nextRelease.channel || "latest"} --npm-only',
// publishCmd is set dynamically below based on branch (prerelease vs stable)
publishCmd: null,
},
],
],
Expand All @@ -58,6 +54,19 @@ const isPrerelease = config.branches.some(
(b) => typeof b === 'object' && b.name === branch && b.prerelease,
);

// On prerelease (main), PyPI is handled by GHA OIDC — keep --npm-only.
// On stable (local release), sdk-release-publish.mjs uploads to PyPI via twine.
const execPlugin = config.plugins.find(
(p) => Array.isArray(p) && p[0] === '@semantic-release/exec',
);
if (isPrerelease) {
execPlugin[1].publishCmd =
'node scripts/sdk-release-publish.mjs --tag ${nextRelease.channel || "latest"} --npm-only';
} else {
execPlugin[1].publishCmd =
'node scripts/sdk-release-publish.mjs --tag ${nextRelease.channel || "latest"}';
}

if (!isPrerelease) {
config.plugins.push([
'@semantic-release/git',
Expand Down
52 changes: 45 additions & 7 deletions packages/sdk/scripts/__tests__/release-order.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,39 @@ test('release-sdk fallback workflow publishes Node SDK via sdk-release-publish',
);
});

test('release-sdk manual version input description matches manual fallback behavior', async () => {
const content = await readRepoFile('.github/workflows/release-sdk.yml');
assert.ok(
content.includes('Leave empty to publish the current repo version.'),
'.github/workflows/release-sdk.yml: manual version input must describe current-version publish behavior',
);
assert.equal(
content.includes('Leave empty to let semantic-release decide.'),
false,
'.github/workflows/release-sdk.yml: manual fallback must not claim semantic-release chooses the version',
);
});

test('release-sdk auto workflow resumes releases from sdk-v tags at HEAD', async () => {
const content = await readRepoFile('.github/workflows/release-sdk.yml');
assert.ok(
content.includes("git tag --points-at HEAD --list 'sdk-v*' --sort=-version:refname | head -n 1"),
'.github/workflows/release-sdk.yml: auto-release must detect sdk release tags at HEAD',
);
assert.ok(
content.includes("if: steps.detect.outputs.release_present == 'true'"),
'.github/workflows/release-sdk.yml: Python publish must key off release tag presence, not per-run tag creation',
);
assert.ok(
content.includes('Resume Node SDK publish for existing release tag'),
'.github/workflows/release-sdk.yml: auto-release must have an npm publish recovery step for reruns',
);
assert.ok(
content.includes('node packages/sdk/scripts/sdk-release-publish.mjs --tag "${{ steps.detect.outputs.dist_tag }}" --npm-only'),
'.github/workflows/release-sdk.yml: npm publish recovery must reuse sdk-release-publish.mjs',
);
});

test('sdk semantic-release prepareCmd builds Node SDK before validate', async () => {
const content = await readRepoFile('packages/sdk/.releaserc.cjs');
assertOrder(
Expand All @@ -77,19 +110,24 @@ test('sdk semantic-release prepareCmd builds Node SDK before validate', async ()
);
});

test('sdk semantic-release main branch uses alpha prerelease on latest channel', async () => {
test('sdk semantic-release matches CLI channel model (next/next on main, latest on stable)', async () => {
const content = await readRepoFile('packages/sdk/.releaserc.cjs');
assert.ok(
content.includes("{ name: 'stable', channel: 'latest' }"),
"packages/sdk/.releaserc.cjs: stable release branch must remain configured",
);
assert.ok(
content.includes("{ name: 'main', prerelease: 'alpha', channel: 'latest' }"),
"packages/sdk/.releaserc.cjs: main branch must release alpha versions on latest",
content.includes("{ name: 'main', prerelease: 'next', channel: 'next' }"),
"packages/sdk/.releaserc.cjs: main branch must release next versions on next channel",
);
assert.equal(
content.includes("prerelease: 'next'"),
false,
"packages/sdk/.releaserc.cjs: SDK release config must not use 'next' prerelease channel",
});

test('sdk-release-publish validates local PyPI prerequisites before Node publish', async () => {
const content = await readRepoFile('packages/sdk/scripts/sdk-release-publish.mjs');
assertOrder(
content,
'const localPypiPublishConfig = resolveLocalPypiPublishConfig({ npmOnly, dryRun });',
"const nodePublishArgs = [path.join(__dirname, 'publish-node-sdk.mjs'), '--tag', tag];",
'packages/sdk/scripts/sdk-release-publish.mjs',
);
});
Loading
Loading