874 lines
38 KiB
YAML
874 lines
38 KiB
YAML
# release / publish
|
|
#
|
|
# Single npm OIDC entry point for both stable releases and prerelease canaries.
|
|
# npm trusted publisher records for the monorepo packages plus independently
|
|
# scoped @copilotkit packages are registered against THIS workflow file.
|
|
# Matching happens on the OIDC token's `workflow_ref` claim, which is always
|
|
# publish-release.yml when this workflow is the entry point.
|
|
#
|
|
# Triggers:
|
|
# - pull_request: closed on a release/publish/<scope>/v<X.Y.Z> branch → stable
|
|
# release of <scope> at version <X.Y.Z> (the normal flow).
|
|
# - workflow_dispatch with mode=stable → manual retrigger of a failed stable
|
|
# release. Republishes from the latest commit on main. Only use this when
|
|
# the normal flow failed BEFORE npm publish succeeded.
|
|
# - workflow_dispatch with mode=prerelease → canary publish. Bumps versions
|
|
# in the build job to <X.Y.Z>-canary.<suffix>, publishes with --tag canary,
|
|
# skips tag push + GH Release + Notion notification.
|
|
name: release / publish
|
|
|
|
# This workflow handles two independent release lanes:
|
|
#
|
|
# 1. npm (TypeScript) — fires on merged release/publish/* PRs or manual dispatch.
|
|
# Build → publish via nx release + OIDC trusted publishers.
|
|
#
|
|
# 2. PyPI (Python SDK) — fires on any merged PR that bumps sdk-python/pyproject.toml.
|
|
# Detects version change vs PyPI registry, builds with poetry, publishes with uv.
|
|
# Ported from ag-ui's publish-release.yml Python lane.
|
|
|
|
on:
|
|
pull_request:
|
|
types: [closed]
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
scope:
|
|
description: "What to release"
|
|
required: true
|
|
type: choice
|
|
options:
|
|
- monorepo
|
|
- angular
|
|
- channels
|
|
- channels-discord
|
|
- channels-intelligence
|
|
- channels-slack
|
|
- channels-teams
|
|
- channels-telegram
|
|
- channels-whatsapp
|
|
mode:
|
|
description: "Release mode: stable (full release with tag + GH Release) or prerelease (canary, no tag/release)"
|
|
required: false
|
|
default: stable
|
|
type: choice
|
|
options:
|
|
- stable
|
|
- prerelease
|
|
suffix:
|
|
description: "Canary suffix (only used when mode=prerelease). Falls back to timestamp if empty. Allowed: [a-zA-Z0-9._-]+"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
dry-run:
|
|
description: "Dry run (skip publish step)"
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
python_publish:
|
|
description: "Run the Python publish lane regardless of which files changed. Still no-ops if sdk-python's version already matches PyPI."
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
# Scope the lock to the package being released so a `monorepo` publish and an
|
|
# `angular` publish run in independent lanes instead of queuing behind each
|
|
# other. On the manual path `inputs.scope` carries the target; on the merged
|
|
# release-PR path inputs are empty, but the PR branch is
|
|
# `release/publish/<scope>/v<version>`, so `github.head_ref` already encodes
|
|
# the scope. Same-scope runs still serialize (cancel-in-progress: false),
|
|
# which is what protects the tag push / npm publish.
|
|
group: publish-release-${{ inputs.scope || github.head_ref || github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
NX_VERBOSE_LOGGING: true
|
|
|
|
jobs:
|
|
build:
|
|
# Run on a merged release PR (normal flow), a stable manual dispatch from
|
|
# main (retry escape hatch), or a prerelease manual dispatch from any
|
|
# selected branch. Canary publishes are intentionally branch-scoped so
|
|
# maintainers can push a button on feature work without merging first.
|
|
if: >
|
|
(github.event_name == 'workflow_dispatch' &&
|
|
(inputs.mode == 'prerelease' || github.ref == 'refs/heads/main')) ||
|
|
(github.event.pull_request.merged == true &&
|
|
startsWith(github.event.pull_request.head.ref, 'release/publish/'))
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Determine scope and mode
|
|
id: meta
|
|
env:
|
|
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
|
INPUT_SCOPE: ${{ inputs.scope }}
|
|
INPUT_MODE: ${{ inputs.mode }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -n "$INPUT_SCOPE" ]; then
|
|
SCOPE="$INPUT_SCOPE"
|
|
else
|
|
# Branch format: release/publish/<scope>/v<version>
|
|
SCOPE="${PR_HEAD_REF#release/publish/}"
|
|
SCOPE="${SCOPE%%/v*}"
|
|
fi
|
|
MODE="${INPUT_MODE:-stable}"
|
|
echo "scope=$SCOPE" >> "$GITHUB_OUTPUT"
|
|
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
|
|
echo "Detected scope: $SCOPE, mode: $MODE"
|
|
|
|
# No token/credential persistence: the publish job sets up its own
|
|
# `git config insteadOf` with secrets.GITHUB_TOKEN before pushing tags,
|
|
# so this checkout doesn't need write access. Critically, the
|
|
# subsequent `Upload workspace` step packs the entire checkout
|
|
# (including .git/config) into an artifact — persisting credentials
|
|
# here would leak a workflow-scoped token to anyone with actions:read.
|
|
- name: Checkout Repo
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Setup pnpm
|
|
# Omit `version:` so pnpm/action-setup inherits from the repo's
|
|
# `packageManager` field in package.json (via corepack).
|
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 22.x
|
|
|
|
- name: Install Dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# Validate user-supplied suffix against npm-safe charset before passing
|
|
# to bump-prerelease.ts. Empty suffix → omit the --suffix flag entirely
|
|
# so the script applies its timestamp fallback (passing an empty string
|
|
# would produce a version like "X.Y.Z-canary." with a trailing dot).
|
|
- name: Bump prerelease versions
|
|
if: ${{ steps.meta.outputs.mode == 'prerelease' }}
|
|
env:
|
|
INPUT_SCOPE: ${{ inputs.scope }}
|
|
INPUT_SUFFIX: ${{ inputs.suffix }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -n "$INPUT_SUFFIX" ]; then
|
|
if ! [[ "$INPUT_SUFFIX" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
|
echo "::error::Invalid suffix '$INPUT_SUFFIX'. Allowed: [a-zA-Z0-9._-]+"
|
|
exit 1
|
|
fi
|
|
pnpm tsx scripts/release/bump-prerelease.ts --scope "$INPUT_SCOPE" --suffix "$INPUT_SUFFIX"
|
|
else
|
|
pnpm tsx scripts/release/bump-prerelease.ts --scope "$INPUT_SCOPE"
|
|
fi
|
|
|
|
- name: Build packages
|
|
run: pnpm run build
|
|
|
|
# Strip caches and pack the workspace into a single tarball before
|
|
# upload. upload-artifact's path filters are post-walk: it still
|
|
# descends into every node_modules and stats every file (~6.4M for
|
|
# this monorepo with pnpm's .pnpm/ symlink farm) before applying
|
|
# negations, which is the actual bottleneck. Removing the dirs and
|
|
# uploading one file collapses that to a single fast step.
|
|
- name: Pack workspace
|
|
run: |
|
|
find . -type d \( -name node_modules -o -name .nx -o -name .turbo -o -name .next \) -prune -exec rm -rf {} +
|
|
tar -czf /tmp/workspace.tgz .
|
|
|
|
- name: Upload workspace
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: workspace
|
|
path: /tmp/workspace.tgz
|
|
retention-days: 1
|
|
|
|
outputs:
|
|
scope: ${{ steps.meta.outputs.scope }}
|
|
mode: ${{ steps.meta.outputs.mode }}
|
|
|
|
publish:
|
|
needs: build
|
|
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
# npm trusted publishing is bound to this environment. Its deployment
|
|
# branch policy must allow prerelease workflow_dispatch refs; stable
|
|
# releases remain main-only via the build job guard.
|
|
environment: npm
|
|
permissions:
|
|
contents: write
|
|
id-token: write
|
|
steps:
|
|
- name: Determine scope and mode
|
|
id: meta
|
|
env:
|
|
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
|
INPUT_SCOPE: ${{ inputs.scope }}
|
|
INPUT_MODE: ${{ inputs.mode }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -n "$INPUT_SCOPE" ]; then
|
|
SCOPE="$INPUT_SCOPE"
|
|
else
|
|
# Branch format: release/publish/<scope>/v<version>
|
|
SCOPE="${PR_HEAD_REF#release/publish/}"
|
|
SCOPE="${SCOPE%%/v*}"
|
|
fi
|
|
if [ -z "$SCOPE" ]; then
|
|
echo "::error::Failed to resolve scope (input=$INPUT_SCOPE, ref=$PR_HEAD_REF)"
|
|
exit 1
|
|
fi
|
|
MODE="${INPUT_MODE:-stable}"
|
|
echo "scope=$SCOPE" >> "$GITHUB_OUTPUT"
|
|
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Download workspace
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: workspace
|
|
|
|
- name: Unpack workspace
|
|
run: |
|
|
tar -xzf workspace.tgz
|
|
rm workspace.tgz
|
|
|
|
- name: Configure git credentials
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
|
|
- name: Setup pnpm
|
|
# Omit `version:` so pnpm/action-setup inherits from the repo's
|
|
# `packageManager` field in package.json (via corepack).
|
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 22.x
|
|
registry-url: https://registry.npmjs.org
|
|
|
|
# Restore node_modules — the build job excludes them from the
|
|
# uploaded workspace artifact (see "Upload workspace" above). Uses
|
|
# pnpm-lock.yaml from the artifact, so this is a deterministic
|
|
# restore of exactly what the build job ran with.
|
|
- name: Install Dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Dry-run notice
|
|
if: ${{ inputs.dry-run == true }}
|
|
run: |
|
|
{
|
|
echo "## Dry Run"
|
|
echo ""
|
|
echo "DRY RUN — skipping publish step. Scope: ${{ steps.meta.outputs.scope }}, mode: ${{ steps.meta.outputs.mode }}."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Publish to npm
|
|
id: publish
|
|
if: ${{ inputs.dry-run != true }}
|
|
env:
|
|
NODE_AUTH_TOKEN: ""
|
|
NOTION_API_KEY: ${{ steps.meta.outputs.mode == 'stable' && secrets.NOTION_API_KEY || '' }}
|
|
PUBLISH_SCRIPT: ${{ steps.meta.outputs.mode == 'prerelease' && 'prerelease.ts' || 'publish-release.ts' }}
|
|
SCOPE: ${{ steps.meta.outputs.scope }}
|
|
run: pnpm tsx "scripts/release/$PUBLISH_SCRIPT" --scope "$SCOPE"
|
|
|
|
- name: Verify publish step emitted version
|
|
if: ${{ success() && inputs.dry-run != true }}
|
|
env:
|
|
MODE: ${{ steps.meta.outputs.mode }}
|
|
VERSION: ${{ steps.publish.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "$VERSION" ]; then
|
|
if [ "$MODE" = "prerelease" ]; then
|
|
echo "::error::prerelease.ts did not emit 'version' output to GITHUB_OUTPUT. The Prerelease summary would render a blank Version field; aborting."
|
|
else
|
|
echo "::error::publish-release.ts did not emit 'version' output to GITHUB_OUTPUT. Tag/release creation would produce malformed artifacts; aborting."
|
|
fi
|
|
exit 1
|
|
fi
|
|
echo "VERSION=$VERSION confirmed (mode=$MODE)"
|
|
|
|
- name: Configure git user
|
|
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
|
|
run: |
|
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
|
git config --local user.name "github-actions[bot]"
|
|
|
|
- name: Check for pre-existing tags
|
|
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
|
|
env:
|
|
SCOPE: ${{ steps.meta.outputs.scope }}
|
|
VERSION: ${{ steps.publish.outputs.version }}
|
|
run: |
|
|
if [ "$SCOPE" == "monorepo" ]; then
|
|
TAG="v${VERSION}"
|
|
else
|
|
TAG="${SCOPE}/v${VERSION}"
|
|
fi
|
|
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
|
echo "ERROR: Tag $TAG already exists" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Create and push git tag
|
|
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
|
|
env:
|
|
SCOPE: ${{ steps.meta.outputs.scope }}
|
|
VERSION: ${{ steps.publish.outputs.version }}
|
|
run: |
|
|
if [ "$SCOPE" == "monorepo" ]; then
|
|
TAG="v${VERSION}"
|
|
else
|
|
TAG="${SCOPE}/v${VERSION}"
|
|
fi
|
|
git tag -a "$TAG" -m "Release ${SCOPE} ${VERSION}"
|
|
git push origin "$TAG"
|
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
id: tag
|
|
|
|
- name: Create GitHub Release
|
|
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
|
env:
|
|
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
|
|
RELEASE_SCOPE: ${{ steps.meta.outputs.scope }}
|
|
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const fs = require("fs");
|
|
const { owner, repo } = context.repo;
|
|
const tag = process.env.RELEASE_TAG;
|
|
const scope = process.env.RELEASE_SCOPE;
|
|
const version = process.env.RELEASE_VERSION;
|
|
const name = scope === "monorepo" ? `v${version}` : `${scope}/v${version}`;
|
|
|
|
let body = "";
|
|
try {
|
|
body = fs.readFileSync("./release-notes.md", "utf8");
|
|
} catch {
|
|
body = `Release ${name}`;
|
|
}
|
|
|
|
try {
|
|
const existing = await github.rest.repos.getReleaseByTag({ owner, repo, tag });
|
|
await github.rest.repos.updateRelease({
|
|
owner, repo,
|
|
release_id: existing.data.id,
|
|
tag_name: tag, name, body,
|
|
draft: false, prerelease: false,
|
|
});
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
await github.rest.repos.createRelease({
|
|
owner, repo,
|
|
tag_name: tag, name, body,
|
|
draft: false, prerelease: false,
|
|
});
|
|
}
|
|
|
|
- name: Release summary (stable)
|
|
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
|
|
run: |
|
|
{
|
|
echo "## Release Published"
|
|
echo ""
|
|
echo "**Scope:** ${{ steps.meta.outputs.scope }}"
|
|
echo "**Mode:** ${{ steps.meta.outputs.mode }}"
|
|
echo "**Version:** ${{ steps.publish.outputs.version }}"
|
|
echo "**Tag:** ${{ steps.tag.outputs.tag }}"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Prerelease summary
|
|
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode == 'prerelease' }}
|
|
run: |
|
|
{
|
|
echo "## Prerelease Published"
|
|
echo ""
|
|
echo "**Scope:** ${{ steps.meta.outputs.scope }}"
|
|
echo "**Version:** ${{ steps.publish.outputs.version }}"
|
|
echo "**Tag:** (prerelease — no tag created)"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Dry-run summary
|
|
if: ${{ success() && inputs.dry-run == true }}
|
|
run: |
|
|
{
|
|
echo "## Dry Run Completed"
|
|
echo ""
|
|
echo "**Scope:** ${{ steps.meta.outputs.scope }}"
|
|
echo "**Mode:** ${{ steps.meta.outputs.mode }}"
|
|
echo "- Publish step was skipped; no npm publish, no git tag, no GitHub Release."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# Populated only on a stable, non-dry-run success (the publish/tag steps are
|
|
# gated on mode != prerelease && dry-run != true). On prerelease, dry-run, or
|
|
# failure these are empty — the notify job gates on that emptiness.
|
|
outputs:
|
|
version: ${{ steps.publish.outputs.version }}
|
|
tag: ${{ steps.tag.outputs.tag }}
|
|
|
|
# ===========================================================================
|
|
# Python SDK publish lane
|
|
#
|
|
# Fires independently of the npm lane. Detects whether sdk-python/pyproject.toml
|
|
# has a version newer than what's on PyPI, builds with poetry, publishes with uv.
|
|
#
|
|
# SECURITY: Same build/publish separation as the npm lane — PYPI_API_TOKEN is
|
|
# only available in the publish-python job, never where poetry install runs.
|
|
# ===========================================================================
|
|
|
|
build-python:
|
|
# Fires when:
|
|
# 1. A PR merging to main touched sdk-python/pyproject.toml (version bump), OR
|
|
# 2. Manual dispatch with python_publish=true
|
|
if: >
|
|
(github.event_name == 'workflow_dispatch' && inputs.python_publish == true) ||
|
|
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
should_publish: ${{ steps.detect.outputs.should_publish }}
|
|
version: ${{ steps.detect.outputs.version }}
|
|
name: ${{ steps.detect.outputs.name }}
|
|
# Earliest Python-release intent signal: emitted by the `changed` step
|
|
# BEFORE any failure-prone step (setup-python, detect, build). The notify
|
|
# job gates the PyPI FAILURE alert on this (not should_publish, which is
|
|
# emitted only at the END of detect) so a build-python failure at/before
|
|
# detect on a genuine release still pages instead of being silently
|
|
# swallowed.
|
|
pyproject_changed: ${{ steps.changed.outputs.pyproject_changed }}
|
|
steps:
|
|
- name: Checkout merged main
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
fetch-depth: 0
|
|
ref: main
|
|
persist-credentials: false
|
|
|
|
# For PRs, skip early if this PR didn't touch pyproject.toml. Manual
|
|
# dispatch always continues (the user explicitly asked for it).
|
|
- name: Check if pyproject.toml changed in this PR
|
|
if: github.event_name == 'pull_request'
|
|
id: changed
|
|
env:
|
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
PR_HEAD_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "$PR_BASE_SHA" ]; then
|
|
echo "::error::PR_BASE_SHA is empty — cannot determine PR base for diff. Refusing to silently skip Python publish."
|
|
exit 1
|
|
fi
|
|
if [ -z "$PR_HEAD_SHA" ]; then
|
|
echo "::error::PR_HEAD_SHA (merge_commit_sha) is empty — GitHub may not have computed the merge commit yet. Refusing to silently skip Python publish; rerun the workflow."
|
|
exit 1
|
|
fi
|
|
# Capture diff FIRST so a git failure trips set -e and fails loudly,
|
|
# rather than producing an empty pipe that grep silently routes to
|
|
# "not changed" — that path masked real version bumps before.
|
|
CHANGED="$(git diff --name-only "$PR_BASE_SHA" "$PR_HEAD_SHA")"
|
|
# grep -q exits 1 on legitimate no-match; guard with `if` so set -e
|
|
# doesn't kill the step on that expected case.
|
|
if printf '%s\n' "$CHANGED" | grep -q '^sdk-python/pyproject.toml$'; then
|
|
echo "pyproject_changed=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "pyproject_changed=false" >> "$GITHUB_OUTPUT"
|
|
echo "sdk-python/pyproject.toml not changed in this PR — skipping Python publish"
|
|
fi
|
|
|
|
- name: Set up Python
|
|
if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.pyproject_changed == 'true'
|
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Detect version change
|
|
if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.pyproject_changed == 'true'
|
|
id: detect
|
|
run: ./scripts/release/detect-py-version-changes.sh
|
|
|
|
- name: Install Poetry
|
|
if: steps.detect.outputs.should_publish == 'true'
|
|
uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2
|
|
with:
|
|
version: latest
|
|
virtualenvs-create: true
|
|
virtualenvs-in-project: true
|
|
|
|
- name: Build Python package
|
|
if: steps.detect.outputs.should_publish == 'true'
|
|
working-directory: sdk-python
|
|
run: poetry build
|
|
|
|
- name: Upload Python build artifacts
|
|
if: steps.detect.outputs.should_publish == 'true'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: py-build-artifacts
|
|
path: sdk-python/dist/
|
|
retention-days: 1
|
|
|
|
- name: Nothing to publish
|
|
if: steps.detect.outputs.should_publish != 'true'
|
|
run: |
|
|
{
|
|
echo "## Python SDK"
|
|
echo ""
|
|
echo "No version change detected — nothing to publish."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# WARNING: PyPI trusted-publisher binding pins to:
|
|
# repository: CopilotKit/CopilotKit
|
|
# workflow_file: publish-release.yml
|
|
# environment: pypi
|
|
# Renaming this file, changing this job's `environment:` value, or moving the
|
|
# publish step into another workflow breaks PyPI publishing with HTTP 422
|
|
# until the Trusted Publisher record on pypi.org is updated to match.
|
|
publish-python:
|
|
needs: build-python
|
|
if: ${{ !cancelled() && needs.build-python.result == 'success' && needs.build-python.outputs.should_publish == 'true' && inputs.dry-run != true }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
environment: pypi
|
|
permissions:
|
|
contents: write
|
|
id-token: write
|
|
steps:
|
|
- name: Checkout merged main
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
fetch-depth: 0
|
|
ref: main
|
|
persist-credentials: false
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
with:
|
|
version: ">=0.8.0"
|
|
|
|
- name: Download Python build artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: py-build-artifacts
|
|
path: sdk-python/dist
|
|
|
|
- name: Publish to PyPI (OIDC trusted publishing)
|
|
run: |
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
files=(sdk-python/dist/*)
|
|
if [ ${#files[@]} -eq 0 ]; then
|
|
echo "::error::no build artifacts in sdk-python/dist — nothing to publish"
|
|
exit 1
|
|
fi
|
|
uv publish --trusted-publishing always "${files[@]}"
|
|
|
|
- name: Verify version is live on PyPI
|
|
env:
|
|
NAME: ${{ needs.build-python.outputs.name }}
|
|
VERSION: ${{ needs.build-python.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
for i in $(seq 1 18); do
|
|
if curl -fsS "https://pypi.org/pypi/${NAME}/${VERSION}/json" >/dev/null 2>&1; then
|
|
echo "Confirmed ${NAME}==${VERSION} on PyPI"; exit 0
|
|
fi
|
|
echo "Attempt ${i}: ${NAME}==${VERSION} not visible yet; retrying in 10s..."
|
|
sleep 10
|
|
done
|
|
echo "::error::${NAME}==${VERSION} did not appear on PyPI within 180s"
|
|
echo "Last curl response:"
|
|
curl -sS "https://pypi.org/pypi/${NAME}/${VERSION}/json" 2>&1 | tail -n 5 || true
|
|
exit 1
|
|
|
|
- name: Configure git
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
|
git config --local user.name "github-actions[bot]"
|
|
git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
|
|
- name: Create and push git tag
|
|
id: tag
|
|
env:
|
|
PY_VERSION: ${{ needs.build-python.outputs.version }}
|
|
PY_NAME: ${{ needs.build-python.outputs.name }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="python-sdk/v${PY_VERSION}"
|
|
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
|
echo "Tag $TAG already exists — skipping"
|
|
else
|
|
git tag -a "$TAG" -m "Release ${PY_NAME} ${PY_VERSION}"
|
|
git push origin "$TAG"
|
|
fi
|
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Create GitHub Release
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
|
env:
|
|
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
|
|
RELEASE_VERSION: ${{ needs.build-python.outputs.version }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const { owner, repo } = context.repo;
|
|
const tag = process.env.RELEASE_TAG;
|
|
const version = process.env.RELEASE_VERSION;
|
|
const name = `python-sdk/v${version}`;
|
|
const body = `Python SDK release: copilotkit ${version}\n\nhttps://pypi.org/project/copilotkit/${version}/`;
|
|
|
|
try {
|
|
const existing = await github.rest.repos.getReleaseByTag({ owner, repo, tag });
|
|
await github.rest.repos.updateRelease({
|
|
owner, repo,
|
|
release_id: existing.data.id,
|
|
tag_name: tag, name, body,
|
|
draft: false, prerelease: false,
|
|
});
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
await github.rest.repos.createRelease({
|
|
owner, repo,
|
|
tag_name: tag, name, body,
|
|
draft: false, prerelease: false,
|
|
});
|
|
}
|
|
|
|
- name: Release summary
|
|
env:
|
|
PY_VERSION: ${{ needs.build-python.outputs.version }}
|
|
run: |
|
|
{
|
|
echo "## Python SDK Published"
|
|
echo ""
|
|
echo "- \`copilotkit@${PY_VERSION}\`"
|
|
echo "- https://pypi.org/project/copilotkit/${PY_VERSION}/"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# ===========================================================================
|
|
# Slack #engr notification
|
|
#
|
|
# A single concise post to #engr when a release publishes (or fails).
|
|
# Runs after both lanes regardless of their outcome (`if: always()`). The
|
|
# load-bearing truth table lives in the unit-tested pure builder at
|
|
# scripts/release/lib/build-release-notification.ts; this job only feeds it the
|
|
# needs.* signals and posts what it returns. Suppressed entirely for canaries
|
|
# (mode=prerelease) and dry-runs — the builder returns should_post=false there.
|
|
# Webhook empty-guard mirrors showcase_validate.yml so an unset
|
|
# SLACK_WEBHOOK_ENGR secret does not break the shell or red this step.
|
|
# ===========================================================================
|
|
notify:
|
|
needs: [build, publish, build-python, publish-python]
|
|
# always() so we still report on a failed lane, but guard on a real release
|
|
# context: a workflow_dispatch (manual release) OR a *merged* PR. A
|
|
# closed-unmerged PR is not a release attempt and must not notify.
|
|
if: >
|
|
always() &&
|
|
(github.event_name == 'workflow_dispatch' ||
|
|
github.event.pull_request.merged == true)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
env:
|
|
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_ENGR }}
|
|
steps:
|
|
# Determine release intent IN THIS JOB, from the github.event payload +
|
|
# the PR changed-files API — NOT from needs.build*/needs.build-python
|
|
# outputs. The build jobs emit their intent signals (should_publish,
|
|
# pyproject_changed) AFTER failure-prone steps (SHA guards, setup-python,
|
|
# the PyPI version-compare), so a build job that dies before emitting them
|
|
# on a REAL release would leave the intent empty → no alert. Computing
|
|
# intent here, independent of whether the build jobs ran at all, closes
|
|
# that silent-swallow class. The builder gates the npm/PyPI FAILURE arms on
|
|
# these signals.
|
|
#
|
|
# This step runs FIRST — before Checkout/Setup/Install — on purpose:
|
|
# computing intent before any infra step means steps.intent.outputs.* are
|
|
# always populated even if a later infra step (the dependency install,
|
|
# checkout, or setup) fails. The failure() self-alert below gates its
|
|
# best-effort Slack post on these outputs, so running intent first
|
|
# guarantees that gate can still fire when the notify job dies during
|
|
# install — exactly the silent-swallow the self-watchdog exists to prevent.
|
|
# It needs only `gh api` (preinstalled), the github.event context, and
|
|
# GITHUB_TOKEN (in its own env), so it has no dependency on checkout/deps.
|
|
- name: Determine release intent
|
|
id: intent
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
# npm intent: a manual dispatch, or a merged release/publish/* PR.
|
|
# Computed purely from event-context expressions (no API needed).
|
|
NPM_INTENDED="${{ (github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/publish/'))) && 'true' || 'false' }}"
|
|
echo "npm_intended=$NPM_INTENDED" >> "$GITHUB_OUTPUT"
|
|
|
|
# Python intent: a python_publish dispatch, OR a merged PR that changed
|
|
# sdk-python/pyproject.toml (per the GitHub PR changed-files API —
|
|
# robust to an uncomputed local merge_commit_sha). Default false.
|
|
PY_INTENDED="false"
|
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
if [ "${{ inputs.python_publish }}" = "true" ]; then PY_INTENDED="true"; fi
|
|
elif [ "${{ github.event.pull_request.merged }}" = "true" ]; then
|
|
# Query the merged PR's changed files. Fail TOWARD paging: if the API
|
|
# call fails on a merged PR, default PY_INTENDED=true (never toward
|
|
# silence) and emit a ::warning::.
|
|
if FILES="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename' 2>/dev/null)"; then
|
|
if printf '%s\n' "$FILES" | grep -qx 'sdk-python/pyproject.toml'; then PY_INTENDED="true"; fi
|
|
else
|
|
echo "::warning::Could not list changed files for PR #${{ github.event.pull_request.number }} via the GitHub API — defaulting py_intended=true (fail toward paging, never toward silence)."
|
|
PY_INTENDED="true"
|
|
fi
|
|
fi
|
|
echo "py_intended=$PY_INTENDED" >> "$GITHUB_OUTPUT"
|
|
echo "npm_intended=$NPM_INTENDED py_intended=$PY_INTENDED"
|
|
|
|
- name: Checkout Repo
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 22.x
|
|
|
|
# Restore node_modules so `pnpm tsx` (and the release-config import the
|
|
# notifier does) resolves. The build/publish jobs install before running
|
|
# tsx for the same reason; without this the notify step fails and NO
|
|
# release notification can ever post.
|
|
- name: Install Dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# Compute the scope-correct npm URL: the monorepo packages live under the
|
|
# @copilotkit org page, while single-package scopes link to their package.
|
|
- name: Resolve npm URL for scope
|
|
id: npmurl
|
|
env:
|
|
SCOPE: ${{ needs.build.outputs.scope }}
|
|
run: |
|
|
set -euo pipefail
|
|
case "$SCOPE" in
|
|
angular)
|
|
NPM_URL="https://www.npmjs.com/package/@copilotkit/angular"
|
|
;;
|
|
*)
|
|
NPM_URL="https://www.npmjs.com/org/copilotkit"
|
|
;;
|
|
esac
|
|
echo "npm_url=$NPM_URL" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Build notification message
|
|
id: build
|
|
env:
|
|
MODE: ${{ needs.build.outputs.mode }}
|
|
NPM_RESULT: ${{ needs.publish.result }}
|
|
NPM_VER: ${{ needs.publish.outputs.version }}
|
|
BUILD_RESULT: ${{ needs.build.result }}
|
|
# Event-derived release intent computed in the `intent` step above
|
|
# (independent of the build jobs). The builder gates the npm/PyPI
|
|
# FAILURE arms on these so a build-job failure on a genuine release
|
|
# always pages, even if the build jobs emitted no usable outputs.
|
|
NPM_INTENDED: ${{ steps.intent.outputs.npm_intended }}
|
|
PY_INTENDED: ${{ steps.intent.outputs.py_intended }}
|
|
# should_publish still legitimately gates the PyPI SUCCESS arm (a real
|
|
# success means detect ran and emitted it).
|
|
PY_PUB: ${{ needs.build-python.outputs.should_publish }}
|
|
PY_RESULT: ${{ needs.publish-python.result }}
|
|
PY_BUILD_RESULT: ${{ needs.build-python.result }}
|
|
PY_VER: ${{ needs.build-python.outputs.version }}
|
|
SCOPE: ${{ needs.build.outputs.scope }}
|
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
# Empty when no tag was created (non-stable / dry-run). The builder's
|
|
# empty-releaseUrl guard that consumes this is retained as
|
|
# DEFENSE-IN-DEPTH: the empty-releaseUrl-on-SUCCESS state is NOT
|
|
# currently reachable — the tag step is `if: success()`, so a tag-step
|
|
# failure flips the publish JOB to `failure` and routes to the failure
|
|
# arm rather than rendering an empty link. The guard protects against a
|
|
# FUTURE change making the tag step continue-on-error (publish success
|
|
# + empty tag output), which would otherwise render a broken empty
|
|
# "<|Release notes>" / "/releases/tag/" link — do NOT remove it.
|
|
RELEASE_URL: ${{ needs.publish.outputs.tag && format('{0}/{1}/releases/tag/{2}', github.server_url, github.repository, needs.publish.outputs.tag) || '' }}
|
|
NPM_URL: ${{ steps.npmurl.outputs.npm_url }}
|
|
# Empty-version guard, mirroring RELEASE_URL above: with no version
|
|
# the per-version PyPI URL would be a broken ".../copilotkit//" link,
|
|
# so fall back to the project root page.
|
|
PY_URL: ${{ needs.build-python.outputs.version && format('https://pypi.org/project/copilotkit/{0}/', needs.build-python.outputs.version) || 'https://pypi.org/project/copilotkit/' }}
|
|
run: pnpm tsx scripts/release/build-release-notification.ts
|
|
|
|
- name: Post to #engr
|
|
if: ${{ steps.build.outputs.should_post == 'true' && env.SLACK_WEBHOOK != '' && inputs.dry-run != true }}
|
|
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
|
with:
|
|
webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }}
|
|
webhook-type: incoming-webhook
|
|
payload: |
|
|
{
|
|
"text": ${{ toJSON(steps.build.outputs.message) }}
|
|
}
|
|
|
|
- name: Log (no Slack — webhook unset)
|
|
if: ${{ steps.build.outputs.should_post == 'true' && env.SLACK_WEBHOOK == '' }}
|
|
run: |
|
|
echo "::warning::A release notification was ready to post but SLACK_WEBHOOK_ENGR is not set; no Slack notification sent."
|
|
|
|
# Self-watchdog: if any earlier step in THIS job failed (e.g. the
|
|
# dependency install or the builder crashed), the notifier itself is the
|
|
# thing that broke — so a real release alert could be silently swallowed.
|
|
# Emit a ::error:: and, when the webhook is configured, a minimal
|
|
# best-effort Slack post so the failure isn't completely invisible.
|
|
- name: Notifier failed — self-alert
|
|
if: ${{ failure() }}
|
|
run: |
|
|
echo "::error::The release notify job failed before it could post — a release alert may have been swallowed. Check this run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
|
|
- name: Notifier failed — best-effort Slack
|
|
# Guard on dry-run: the self-watchdog Slack post must honor the same
|
|
# silence invariant as the real notification — a dry-run is silent
|
|
# EVERYWHERE, so a notify-job failure during one must not post a false
|
|
# red page.
|
|
#
|
|
# ALSO guard on real-release-context via the robust, build-job-INDEPENDENT
|
|
# intent computed in the `intent` step: the notify job runs on EVERY
|
|
# merged PR (always()), so a routine non-release merge whose notify job
|
|
# hits a transient install/builder flake would otherwise self-page even
|
|
# though no release was attempted. Only self-alert when a release was
|
|
# actually in flight (npm_intended OR py_intended). This replaces the old
|
|
# npm-biased `mode != 'prerelease'` + should_publish/pyproject_changed
|
|
# heuristic — which both relied on the build jobs' outputs (the very
|
|
# signals that may be empty if a build job died) AND would have suppressed
|
|
# a python_publish self-alert under a prerelease-mode dispatch. The intent
|
|
# gate is correct and robust. (The ::error:: echo step above stays
|
|
# unconditional — only the Slack POST needs these guards.)
|
|
if: ${{ failure() && env.SLACK_WEBHOOK != '' && inputs.dry-run != true && (steps.intent.outputs.npm_intended == 'true' || steps.intent.outputs.py_intended == 'true') }}
|
|
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
|
with:
|
|
webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }}
|
|
webhook-type: incoming-webhook
|
|
payload: |
|
|
{
|
|
"text": ${{ toJSON(format('🔴 *CopilotKit release notifier failed* — a release alert may have been swallowed · <{0}/{1}/actions/runs/{2}|View run>', github.server_url, github.repository, github.run_id)) }}
|
|
}
|