Skip to content

ci: harden CI/CD — SHA-pin actions, bump pnpm to 11.1.2, add zizmor #27

ci: harden CI/CD — SHA-pin actions, bump pnpm to 11.1.2, add zizmor

ci: harden CI/CD — SHA-pin actions, bump pnpm to 11.1.2, add zizmor #27

Workflow file for this run

name: release / publish
# Mirrors CopilotKit's `release / publish` DX. Fires automatically on any
# merged PR to main, and also supports manual dispatch (useful for retries
# after a partial failure, or for forcing a publish of a version that's
# already on main but not yet on the registries).
#
# We differ from CopilotKit in one way: we detect version changes by
# diffing against the npm/PyPI registries (rather than parsing the merged
# branch name), which lets BOTH the automated release-PR flow and plain
# version-bump PRs from external maintainers trigger a publish.
#
# Handles stable AND prerelease versions:
# "1.2.3" → publishes to npm `latest` / PyPI normal
# "1.2.3-alpha.0" → publishes to npm `alpha` / PyPI (pip needs --pre)
# "1.2.3a0" / "1.2.3b0" / "1.2.3rc0" → same, with PEP 440 prerelease tags
#
# SECURITY: Build and publish are split into separate jobs. Publishing
# secrets (NPM_TOKEN, PYPI_API_TOKEN) are only available in the publish
# job, never in the same process tree as build-time code execution.
on:
# Version bumps can only live in package.json or pyproject.toml, so we
# gate the trigger on those paths. Combined with the release.config.json
# allowlist in detect-ts-version-changes.sh this gives defense in depth:
# unrelated PRs don't even start the workflow, and any workflow run that
# does fire still filters to enrolled packages before touching a registry.
pull_request:
types: [closed]
branches: [main]
paths:
- "**/package.json"
- "**/pyproject.toml"
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (detect but don't publish). Useful for previewing."
required: false
default: false
type: boolean
concurrency:
group: release-publish
cancel-in-progress: false
env:
NX_VERBOSE_LOGGING: true
permissions:
contents: read
jobs:
build:
# Fires on merged release PRs OR on manual dispatch (retry / forced publish).
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
outputs:
ts_packages: ${{ steps.detect_ts.outputs.packages }}
ts_count: ${{ steps.detect_ts.outputs.count }}
py_packages: ${{ steps.detect_py.outputs.packages }}
py_count: ${{ steps.detect_py.outputs.count }}
ts_groups_json: ${{ steps.save_groups.outputs.groups }}
steps:
- name: Checkout merged main
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0
ref: main
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: "10.33.4"
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
version: "25.x"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: ">=0.8.0"
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install Poetry
run: pip install poetry
- name: Install packaging library
run: pip install packaging
- name: Install dependencies
# No cache — release builds must be hermetic (cold cache, clean install).
# This is a deliberate security measure: cached pnpm stores could be
# poisoned via a prior compromised build, breaking the supply chain
# regardless of job separation.
run: pnpm install --frozen-lockfile
- name: Detect TypeScript version changes vs npm
id: detect_ts
run: |
CHANGED=$(bash scripts/release/detect-ts-version-changes.sh 2>detect-ts.log || echo "[]")
cat detect-ts.log || true
[ -z "$CHANGED" ] && CHANGED="[]"
[ "$CHANGED" = "null" ] && CHANGED="[]"
echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$CHANGED" | jq 'length')
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
- name: Detect Python version changes vs PyPI
id: detect_py
run: |
CHANGED=$(bash scripts/release/detect-py-version-changes.sh 2>detect-py.log || echo "[]")
cat detect-py.log || true
[ -z "$CHANGED" ] && CHANGED="[]"
[ "$CHANGED" = "null" ] && CHANGED="[]"
echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$CHANGED" | jq 'length')
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
- name: Nothing to publish
if: steps.detect_ts.outputs.count == '0' && steps.detect_py.outputs.count == '0'
run: |
{
echo "## release / publish"
echo ""
echo "No version changes detected — nothing to publish."
} >> "$GITHUB_STEP_SUMMARY"
- name: Extract TypeScript project names
if: steps.detect_ts.outputs.count != '0'
id: ts_projects
env:
TS_PACKAGES: ${{ steps.detect_ts.outputs.packages }}
run: |
PROJECTS=$(echo "$TS_PACKAGES" | jq -r '[.[].name] | join(",")')
echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT"
echo "Scoped build/test to TypeScript projects: $PROJECTS"
- name: Build TypeScript packages in scope
if: steps.detect_ts.outputs.count != '0'
run: npx nx run-many -t build --projects="${STEPS_TS_PROJECTS_OUTPUTS_PROJECTS}"
env:
STEPS_TS_PROJECTS_OUTPUTS_PROJECTS: ${{ steps.ts_projects.outputs.projects }}
- name: Test TypeScript packages in scope
if: steps.detect_ts.outputs.count != '0'
run: npx nx run-many -t test --projects="${STEPS_TS_PROJECTS_OUTPUTS_PROJECTS}"
env:
STEPS_TS_PROJECTS_OUTPUTS_PROJECTS: ${{ steps.ts_projects.outputs.projects }}
- name: Group TypeScript packages by dist-tag
if: steps.detect_ts.outputs.count != '0'
id: ts_groups
env:
TS_PACKAGES: ${{ steps.detect_ts.outputs.packages }}
run: |
echo "$TS_PACKAGES" > /tmp/ts-packages.json
python3 <<'PYEOF' > /tmp/ts-groups.json
import json, re
packages = json.load(open('/tmp/ts-packages.json'))
groups = {}
for pkg in packages:
v = pkg['version']
# Prerelease → dist-tag is the preid (e.g. "1.2.3-alpha.0" → alpha)
m = re.match(r'^\d+\.\d+\.\d+-([a-zA-Z]+)(?:[.\-].*)?$', v)
tag = m.group(1) if m else 'latest'
groups.setdefault(tag, []).append(pkg['name'])
print(json.dumps(groups))
PYEOF
cat /tmp/ts-groups.json
- name: Save groups to output
if: steps.detect_ts.outputs.count != '0'
id: save_groups
run: |
{
echo "groups<<EOF"
cat /tmp/ts-groups.json
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Upload TypeScript build artifacts
if: steps.detect_ts.outputs.count != '0'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ts-build-artifacts
path: |
sdks/typescript/packages/*/dist/
integrations/*/typescript/dist/
middlewares/*/dist/
retention-days: 1
- name: Build Python packages
if: steps.detect_py.outputs.count != '0'
env:
PY_PACKAGES: ${{ steps.detect_py.outputs.packages }}
run: |
while read -r pkg; do
DIR=$(echo "$pkg" | jq -r '.dir')
BUILD_SYSTEM=$(echo "$pkg" | jq -r '.build_system')
NAME=$(echo "$pkg" | jq -r '.name')
echo "=== Building ${NAME} from ${DIR} (${BUILD_SYSTEM}) ==="
cd "$DIR"
if [ "$BUILD_SYSTEM" = "poetry" ]; then
poetry build
else
uv build
fi
cd "$GITHUB_WORKSPACE"
done < <(echo "$PY_PACKAGES" | jq -c '.[]')
- name: Upload Python build artifacts
if: steps.detect_py.outputs.count != '0'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: py-build-artifacts
path: |
sdks/python/dist/
integrations/*/python/dist/
retention-days: 1
publish:
needs: build
if: >
(needs.build.outputs.ts_count != '0' || needs.build.outputs.py_count != '0') &&
(github.event_name == 'workflow_dispatch' && inputs.dry_run != true || github.event_name != 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
id-token: write
steps:
- name: Checkout merged main
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
persist-credentials: false
# --- TypeScript publish ---
- name: Setup pnpm
if: needs.build.outputs.ts_count != '0'
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: "10.33.4"
- name: Setup Node
if: needs.build.outputs.ts_count != '0'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
registry-url: https://registry.npmjs.org
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Configure npm auth
if: needs.build.outputs.ts_count != '0'
run: |
npm config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Download TypeScript build artifacts
if: needs.build.outputs.ts_count != '0'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ts-build-artifacts
# nx release publish needs the full pnpm workspace installed so that:
# 1. nx can resolve the project graph and find the nx-release-publish executor
# 2. pnpm publish can resolve workspace:* protocol deps to real versions
# We use --ignore-scripts to preserve the security boundary: no lifecycle
# scripts run in the same process tree as publishing secrets.
- name: Install dependencies (no lifecycle scripts)
if: needs.build.outputs.ts_count != '0'
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Publish TypeScript packages
if: needs.build.outputs.ts_count != '0'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
TS_GROUPS: ${{ needs.build.outputs.ts_groups_json }}
run: |
echo "$TS_GROUPS" > /tmp/ts-groups.json
TS_FAILED=""
for TAG in $(jq -r 'keys[]' /tmp/ts-groups.json); do
for PROJECT in $(jq -r --arg t "$TAG" '.[$t][]' /tmp/ts-groups.json); do
echo "=== Publishing ${PROJECT} with --tag ${TAG} ==="
if ! npx nx release publish --projects="$PROJECT" --tag "$TAG"; then
echo "FAILED: ${PROJECT}"
TS_FAILED="${TS_FAILED} ${PROJECT}"
fi
done
done
if [ -n "$TS_FAILED" ]; then
echo ""
echo "::error::The following TypeScript packages failed to publish:${TS_FAILED}"
echo "TS_PUBLISH_FAILED=true" >> "$GITHUB_ENV"
fi
# --- Python publish ---
- name: Install uv
if: needs.build.outputs.py_count != '0'
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: ">=0.8.0"
- name: Download Python build artifacts
if: needs.build.outputs.py_count != '0'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: py-build-artifacts
- name: Publish Python packages
if: needs.build.outputs.py_count != '0'
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
PY_PACKAGES: ${{ needs.build.outputs.py_packages }}
run: |
PY_FAILED=""
while read -r pkg; do
NAME=$(echo "$pkg" | jq -r '.name')
VERSION=$(echo "$pkg" | jq -r '.version')
DIR=$(echo "$pkg" | jq -r '.dir')
echo "=== Publishing ${NAME}@${VERSION} from ${DIR} ==="
if [ -d "${DIR}/dist" ]; then
if ! uv publish "${DIR}/dist/*"; then
echo "FAILED: ${NAME}"
PY_FAILED="${PY_FAILED} ${NAME}"
fi
else
echo "WARNING: No dist/ found for ${NAME} at ${DIR} — skipping"
PY_FAILED="${PY_FAILED} ${NAME}"
fi
done < <(echo "$PY_PACKAGES" | jq -c '.[]')
if [ -n "$PY_FAILED" ]; then
echo ""
echo "::error::The following Python packages failed to publish:${PY_FAILED}"
echo "PY_PUBLISH_FAILED=true" >> "$GITHUB_ENV"
fi
# --- Git tags and GitHub Releases ---
- name: Configure git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Configure git credentials for push
run: git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push per-package git tags (TypeScript)
if: needs.build.outputs.ts_count != '0'
env:
TS_PACKAGES: ${{ needs.build.outputs.ts_packages }}
run: bash scripts/release/create-tags.sh "$TS_PACKAGES"
- name: Create and push per-package git tags (Python)
if: needs.build.outputs.py_count != '0'
env:
PY_PACKAGES: ${{ needs.build.outputs.py_packages }}
run: bash scripts/release/create-tags.sh "$PY_PACKAGES"
- name: Create GitHub Release (TypeScript)
if: needs.build.outputs.ts_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TS_PACKAGES: ${{ needs.build.outputs.ts_packages }}
run: bash scripts/release/create-or-update-release.sh typescript "$TS_PACKAGES"
- name: Create GitHub Release (Python)
if: needs.build.outputs.py_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PY_PACKAGES: ${{ needs.build.outputs.py_packages }}
run: bash scripts/release/create-or-update-release.sh python "$PY_PACKAGES"
- name: Reconcile GitHub Release (TypeScript) — safety net for partial failures
if: needs.build.outputs.ts_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TS_PACKAGES: ${{ needs.build.outputs.ts_packages }}
run: bash scripts/release/reconcile-release.sh typescript "$TS_PACKAGES"
- name: Reconcile GitHub Release (Python) — safety net for partial failures
if: needs.build.outputs.py_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PY_PACKAGES: ${{ needs.build.outputs.py_packages }}
run: bash scripts/release/reconcile-release.sh python "$PY_PACKAGES"
- name: Delete release/next if that's what was merged
if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'release/next'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git push origin --delete release/next 2>/dev/null || echo "Branch already gone"
- name: Release summary
env:
TS_PACKAGES: ${{ needs.build.outputs.ts_packages }}
PY_PACKAGES: ${{ needs.build.outputs.py_packages }}
run: |
{
echo "## release / publish"
echo ""
if [ "$(echo "$TS_PACKAGES" | jq 'length')" -gt 0 ]; then
echo "### npm"
echo ""
echo "$TS_PACKAGES" | jq -r '.[] | "- `\(.name)@\(.version)`"'
echo ""
fi
if [ "$(echo "$PY_PACKAGES" | jq 'length')" -gt 0 ]; then
echo "### PyPI"
echo ""
echo "$PY_PACKAGES" | jq -r '.[] | "- `\(.name)@\(.version)`"'
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if any packages failed to publish
if: env.TS_PUBLISH_FAILED == 'true' || env.PY_PUBLISH_FAILED == 'true'
run: |
echo "One or more packages failed to publish. See errors above."
exit 1