Skip to content

fix(update): make the update flow report the truth and stop wedging (… #88

fix(update): make the update flow report the truth and stop wedging (…

fix(update): make the update flow report the truth and stop wedging (… #88

Workflow file for this run

name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
# Publishing to npm is irreversible — never let a commit that fails CI or
# Nix Build ship. Those workflows run in parallel on the same push, so this
# job blocks the release until both conclude successfully for this SHA.
wait-for-checks:
name: Wait for CI and Nix Build
runs-on: ubuntu-latest
if: github.repository_owner == 'Pythoughts-labs'
timeout-minutes: 45
permissions:
actions: read
contents: read
steps:
- name: Wait for required workflows on this commit
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
for workflow in "CI" "Nix Build"; do
echo "Waiting for workflow: $workflow"
while true; do
# Transient API failures must not kill the gate (step shell is -e).
pair=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=50" \
--jq "[.workflow_runs[] | select(.name==\"$workflow\")][0] | \"\(.status)/\(.conclusion)\"" \
2>/dev/null) || pair="api-error/null"
case "$pair" in
completed/success)
echo "$workflow: success"; break ;;
completed/*)
echo "::error::$workflow concluded '${pair#completed/}' for $SHA — refusing to release."
exit 1 ;;
*)
echo "$workflow: $pair — waiting..."; sleep 30 ;;
esac
done
done
release:
name: Release
needs: wait-for-checks
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.repository_owner == 'Pythoughts-labs'
outputs:
packages_published: ${{ steps.changesets.outputs.published }}
pythinker_native_release: ${{ steps.pythinker-release.outputs.should_publish }}
pythinker_release_tag: ${{ steps.pythinker-release.outputs.tag }}
permissions:
contents: write
pull-requests: write
id-token: write # Required for NPM Trusted Publishing (OIDC)
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Pythinker Code built-in catalog
shell: bash
run: |
CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json"
node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
- name: Build packages
run: pnpm build
- name: Mint release-bot token
id: release-bot
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: node scripts/release/changeset-publish-idempotent.mjs
version: pnpm run version:release
commit: "ci: release packages"
title: "ci: release packages"
env:
# App token (not GITHUB_TOKEN) so the version PR triggers pull_request
# workflows and required status checks / CodeRabbit run on it.
GITHUB_TOKEN: ${{ steps.release-bot.outputs.token }}
# No NPM_TOKEN on purpose: changesets prefers it over OIDC when set, so
# defining it would silently downgrade publishing to a long-lived token.
- name: Request CodeRabbit review on version PR
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
run: |
pr=$(gh pr list --head changeset-release/main --state open --json number --jq '.[0].number' || true)
if [ -n "$pr" ] && [ "$pr" != "null" ]; then
gh pr comment "$pr" --body '@coderabbitai review'
fi
- name: Resolve Pythinker Code native release
if: steps.changesets.outputs.published == 'true'
id: pythinker-release
run: node apps/pythinker-code/scripts/native/resolve-release.mjs
env:
CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
# The VS Code extension is a private workspace package: changesets bumps its
# version but never publishes it to npm, so it ships from here instead. Both
# publish scripts skip packages that already exist in the registry, so this
# job is a no-op on releases that did not touch the extension.
publish-vscode-extension:
timeout-minutes: 45
name: Publish VS Code extension
needs: release
if: needs.release.outputs.packages_published == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Pythinker Code built-in catalog
shell: bash
run: |
CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json"
node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
- name: Build workspace packages
run: pnpm build
# Packages all six platform targets and runs the VSIX audit on each one.
# A failure here must stop the job before anything reaches a registry.
- name: Package and verify VSIX targets
run: pnpm --filter pythinker-code run package:platform
- name: Publish to the Visual Studio Marketplace
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [ -z "$VSCE_PAT" ]; then
echo "::warning::VSCE_PAT secret not set — skipping Marketplace publish."
exit 0
fi
pnpm --filter pythinker-code run publish:vsix
# Open VSX serves Cursor / VSCodium / Windsurf. A failure here must not
# undo an already-successful Marketplace publish, so it only warns.
- name: Publish to Open VSX
continue-on-error: true
env:
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [ -z "$OVSX_PAT" ]; then
echo "::warning::OVSX_PAT secret not set — skipping Open VSX publish."
exit 0
fi
pnpm --filter pythinker-code run publish:ovsx
- name: Upload VSIX artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: pythinker-code-vsix
path: apps/vscode/artifacts/vsix/*.vsix
retention-days: 7
if-no-files-found: error
# code.pythinker.com redeploys via Dokploy autodeploy on push to main (app
# Pythinker/code builds apps/site/Dockerfile from the repo), so no deploy
# webhook is fired here — this job verifies that the published release is
# internally consistent and that the CDN is not advertising a version npm
# does not have.
#
# It also runs on a `ci: release packages` merge that published nothing: that
# commit bumps the version on main, so gating the check on a successful
# publish hid the one case where the version and the published artifacts
# diverge — and every client polled the CDN for a release that never existed.
verify-cdn-release:
timeout-minutes: 15
name: Verify release consistency
needs: release
if: >-
needs.release.outputs.packages_published == 'true'
|| startsWith(github.event.head_commit.message, 'ci: release packages')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Verify release consistency
run: node scripts/release/verify-release-consistency.mjs
update-brew-tap:
timeout-minutes: 15
name: Update Homebrew tap
needs: release
if: needs.release.outputs.packages_published == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Bump formula
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
run: |
if [ -z "$TAP_GITHUB_TOKEN" ]; then
echo "TAP_GITHUB_TOKEN secret not set — skipping tap update" >&2
exit 0
fi
node scripts/release/update-brew-formula.mjs
deploy-docs:
name: Deploy docs
needs: release
if: needs.release.outputs.packages_published == 'true'
uses: ./.github/workflows/docs-deploy.yml
permissions:
contents: read
pages: write
id-token: write
native-artifacts:
name: Native release artifact
needs: release
if: needs.release.outputs.pythinker_native_release == 'true'
uses: ./.github/workflows/_native-build.yml
with:
upload-artifact-prefix: pythinker-code-native
retention-days: 7
sign-macos: true
secrets:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
publish-native-assets:
timeout-minutes: 15
name: Publish native release assets
needs:
- release
- native-artifacts
if: needs.release.outputs.pythinker_native_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download native artifacts
uses: actions/download-artifact@v8
with:
pattern: pythinker-code-native-*
path: dist-native-release
merge-multiple: true
- name: Assert all native targets are present
run: |
missing=0
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-arm64 win32-x64; do
if ! ls dist-native-release/pythinker-code-"$target".zip >/dev/null 2>&1; then
echo "::error::Missing native bundle for $target — refusing to publish a partial release."
missing=1
fi
done
exit $missing
- name: Produce manifest.json
env:
RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }}
run: node apps/pythinker-code/scripts/native/produce-manifest.mjs dist-native-release "$RELEASE_TAG"
- name: Upload assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }}
run: gh release upload "$RELEASE_TAG" dist-native-release/* --clobber