# 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//v branch → stable # release of at version (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 -canary., 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//v`, 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//v 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//v 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)) }} }