ci: harden CI/CD — SHA-pin actions, bump pnpm to 11.1.2, add zizmor #27
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |