Skip to content

feat(workflow): per-task subagent routing and a spawn-plan telemetry … #260

feat(workflow): per-task subagent routing and a spawn-plan telemetry …

feat(workflow): per-task subagent routing and a spawn-plan telemetry … #260

Workflow file for this run

name: Release
on:
push:
branches:
- main
# Never cancel a release in flight: a publish that is interrupted between npm
# and the git tags cannot be undone. Runs therefore queue instead. GitHub keeps
# only one pending run per group, so a third push while a release is running
# silently drops the one waiting behind it — the commits still ship, on the
# next run, but nothing announces the skip. Keeping this job short is what
# keeps that window small.
concurrency: ${{ github.workflow }}-${{ github.ref }}
# This repository installs a local pre-push gate that re-runs the full test
# suite. It exists to save a CI round trip from a developer's machine; inside
# CI it is pure duplication, and `scripts/pre-push.sh` reads this to stand
# down. Set at the workflow level so it reaches every push, including the ones
# a JS action spawns.
env:
SKIP_HOOKS: "1"
# Fail closed: a job gets no token scope it does not ask for, so the repository
# or organisation default can never quietly hand write access to a job that
# only reads. Every job below grants itself exactly what it needs.
permissions: {}
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 == 'PyModel'
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 == 'PyModel'
outputs:
packages_published: ${{ steps.changesets.outputs.published }}
cli_version_bumped: ${{ steps.lane-versions.outputs.cli_version_bumped }}
cli_version: ${{ steps.lane-versions.outputs.cli_version }}
desktop_version_bumped: ${{ steps.lane-versions.outputs.desktop_version_bumped }}
desktop_version: ${{ steps.lane-versions.outputs.desktop_version }}
extension_version_bumped: ${{ steps.lane-versions.outputs.extension_version_bumped }}
extension_version: ${{ steps.lane-versions.outputs.extension_version }}
# The Changesets output is true on the first publish. The push-boundary
# signal remains true on a rerun, where the idempotent wrapper correctly
# exits without republishing an npm version that is already live.
pythinker_native_release: ${{ steps.pythinker-release.outputs.should_publish == 'true' || steps.lane-versions.outputs.cli_version_bumped == 'true' }}
# `pythinker-release` only runs when changesets publishes, so on the
# rerun path above its tag output is empty. Fall back to the same string
# resolve-release.mjs builds, or publish-native-assets would run
# `gh release view ""`.
pythinker_release_tag: ${{ steps.pythinker-release.outputs.tag || format('@pymodel/pythinker-code@{0}', steps.lane-versions.outputs.cli_version) }}
permissions:
contents: write
pull-requests: write
id-token: write # Required for NPM Trusted Publishing (OIDC)
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@11
# `github.event.before` is the exact start of this push. HEAD^ only sees
# the final commit and misses version bumps in a multi-commit push.
- name: Detect release lane version bumps
id: lane-versions
env:
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
run: node scripts/release/detect-lane-bumps.mjs "$BEFORE_SHA" "$AFTER_SHA"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The `prepare` script reinstalls the local hooks during the install
# above, so this has to run after it, not before. `SKIP_HOOKS` already
# tells the hook to stand down; deleting it as well removes the last way
# the suite could run inside this job — an environment that reaches the
# hook scrubbed, or a hook that stops honouring the variable.
#
# Worth the belt and braces: on the publish path, the push these hooks
# would gate carries the version tags, and it runs after npm has already
# accepted the packages. A hook failure there — one flaky test under
# runner load is enough — leaves the release published but untagged, and
# skips every job that depends on it: docs, native artifacts, the VS Code
# extension, the Homebrew tap, the CDN.
- name: Disarm the local git hooks
run: |
# `--git-path hooks` is git's own resolution of core.hooksPath, so
# this deletes from the directory git will actually read rather than
# assuming `.git/hooks`.
hooks_dir=$(git rev-parse --git-path hooks)
rm -f -- "${hooks_dir}/pre-push" "${hooks_dir}/pre-commit"
if [ -e "${hooks_dir}/pre-push" ] || [ -e "${hooks_dir}/pre-commit" ]; then
echo "::error::A git hook survived removal in ${hooks_dir}."
echo "::error::The release push would re-run the full test suite; fix this before releasing."
exit 1
fi
echo "Local git hooks disarmed (${hooks_dir})."
- 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@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from 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@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # pinned from 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.
# Release cadence switch. With this on, the version PR merges itself once
# every required check passes, so one merge to main becomes one release
# and the version tracks each change instead of collapsing a backlog of
# changesets into a single jump. Turn it off to go back to releasing by
# hand: `gh variable set AUTO_MERGE_RELEASE_PR --body false`.
#
# This is deliberately not a blanket auto-merge. It only ever targets the
# changesets-authored branch, the repository requires its status checks
# before any merge, and a major bump is gated separately on the pull
# request that introduces the changeset — so an unattended release can
# still never rename the major version on its own.
#
# Never fail the release over this: a version PR that stays open costs a
# manual merge, while a failure here would block npm, the Marketplace and
# the CDN behind it.
# Resolve the version PR once, for every step that acts on it.
#
# The changesets action reports the pull request it just created or
# updated, which is the only unambiguous identifier. The fallback covers
# the run where changesets had nothing to change, and it is scoped to a
# head branch in THIS repository by full `owner/name`: `--head` matches a
# branch name only, so a head-name match on its own can select a fork's
# branch of the same name, and matching the owner alone would still accept
# a different repository belonging to that owner.
- name: Resolve the version PR
id: version-pr
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
CHANGESETS_PR: ${{ steps.changesets.outputs.pullRequestNumber }}
run: |
set -uo pipefail
if [ -n "${CHANGESETS_PR}" ] && [ "${CHANGESETS_PR}" != "null" ]; then
echo "number=${CHANGESETS_PR}" >> "${GITHUB_OUTPUT}"
echo "Version PR #${CHANGESETS_PR}, as reported by the changesets action."
exit 0
fi
if ! open_prs=$(gh pr list \
--repo "${GITHUB_REPOSITORY}" \
--head changeset-release/main \
--base main \
--state open \
--json number,headRepository); then
echo "::warning::Could not look up the version PR. Nothing downstream will run; check it by hand."
exit 0
fi
number=$(printf '%s' "${open_prs}" \
| jq -r --arg repo "${GITHUB_REPOSITORY}" \
'[.[] | select(.headRepository.nameWithOwner == $repo)][0].number // ""')
if [ -z "${number}" ]; then
echo "::notice::No open version PR in this repository."
exit 0
fi
echo "number=${number}" >> "${GITHUB_OUTPUT}"
echo "Version PR #${number}."
# Release cadence switch. With this on, the version PR merges itself once
# every required check passes, so one merge to main becomes one release
# and the version tracks each change instead of collapsing a backlog of
# changesets into a single jump. Turn it off to go back to releasing by
# hand: `gh variable set AUTO_MERGE_RELEASE_PR --body false`.
#
# This is deliberately not a blanket auto-merge. It only ever targets the
# changesets-authored branch in this repository, the repository requires
# its status checks before any merge, and a major bump is gated separately
# on the pull request that introduces the changeset — so an unattended
# release can still never rename the major version on its own.
#
# A changeset that lands while this pull request is waiting on its checks
# joins the same release rather than starting the next one. That window is
# how changesets works, not something this step can close; keeping one
# changeset per pull request keeps it small.
#
# Never fail the release over this: a version PR that stays open costs a
# manual merge, while a failure here would block npm, the Marketplace and
# the CDN behind it.
- name: Enable auto-merge on the version PR
if: steps.version-pr.outputs.number != '' && vars.AUTO_MERGE_RELEASE_PR == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
PR: ${{ steps.version-pr.outputs.number }}
run: |
set -uo pipefail
if gh pr merge "${PR}" --squash --auto; then
echo "Auto-merge armed on #${PR}; it lands when its required checks pass."
else
echo "::warning::Could not arm auto-merge on #${PR}. Merge it by hand to cut the release."
fi
- name: Request CodeRabbit review on version PR
if: steps.version-pr.outputs.number != ''
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
PR: ${{ steps.version-pr.outputs.number }}
run: gh pr comment "${PR}" --body '@coderabbitai review'
- 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 }}
# Desktop tagging is isolated from npm and VS Code. A tag failure turns this
# workflow red without preventing the other lanes from reporting their state.
cut-desktop-tag:
timeout-minutes: 10
name: Cut desktop release tag
needs: release
if: >-
needs.release.outputs.desktop_version_bumped == 'true'
&& vars.RELEASE_LANE_DESKTOP != 'disabled'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
persist-credentials: false
# GITHUB_TOKEN-created tags cannot trigger another workflow. The App
# token makes `desktop-v*` start desktop-release.yml.
- name: Mint release-bot token
id: release-bot
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2
with:
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
permission-contents: write
- name: Cut required desktop tag
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
DESKTOP_VERSION: ${{ needs.release.outputs.desktop_version }}
run: |
set -euo pipefail
tag="desktop-v${DESKTOP_VERSION}"
existing="$(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" |
awk '$2 ~ /\^\{\}$/ { peeled=$1 } $2 !~ /\^\{\}$/ { direct=$1 } END { print (peeled ? peeled : direct) }')"
if [ -n "$existing" ]; then
if [ "$existing" != "$GITHUB_SHA" ]; then
echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}."
exit 1
fi
echo "::notice::${tag} already points at ${GITHUB_SHA}; nothing to do."
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${tag}" \
-f sha="${GITHUB_SHA}" > /dev/null; then
echo "::error::Could not cut ${tag}. Recovery: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}"
exit 1
fi
echo "Cut ${tag} at ${GITHUB_SHA}."
# The extension lane runs only when its own version changes. The reusable
# workflow also supports a version-checked manual recovery run.
publish-vscode-extension:
name: Publish VS Code extension
needs: release
if: >-
needs.release.outputs.extension_version_bumped == 'true'
&& vars.RELEASE_LANE_VSCODE != 'disabled'
uses: ./.github/workflows/vscode-release.yml
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
with:
expected-version: ${{ needs.release.outputs.extension_version }}
secrets:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
# code.pythinker.com redeploys via Dokploy autodeploy on push to main (app
# Pythinker/code builds apps/site/Dockerfile from the repo). That autodeploy
# fires on the `ci: release packages` push — which STARTS the release — while
# apps/site/scripts/build-cdn.mjs reads the version from npm's dist-tag, which
# only moves when the publish FINISHES. The first build therefore bakes in the
# previous version and nothing rebuilds it, so the release stays invisible to
# every installed client. This job fires a second deploy after the publish.
#
# It must run after publish-native-assets: latest.json only gets its
# per-platform `platforms` block once the native zips exist on the release.
# That job is itself conditional and SKIPS on an npm-only release, and a job
# whose `needs` includes a skipped job is skipped too — hence `always()`, and
# hence the explicit upstream result assertions it forces us to spell out.
redeploy-cdn:
timeout-minutes: 10
name: Redeploy CDN
# Posts to a webhook with a secret; it never touches the GitHub API.
permissions: {}
needs:
- release
- publish-native-assets
# A native release whose zips never reached the GitHub release must not
# reach the CDN either. `publish-native-assets` refuses to publish a
# partial set, and a job whose needs failed reports `skipped`, not
# `failure` — so checking only for failure let a release with zero assets
# through and pointed every native installer at a release that has none.
# Requiring success (only when this release has native artifacts at all)
# leaves the CDN on the last installable version instead.
if: >-
always()
&& needs.release.result == 'success'
&& needs.release.outputs.pythinker_native_release == 'true'
&& needs.publish-native-assets.result == 'success'
&& vars.RELEASE_LANE_CDN != 'disabled'
runs-on: ubuntu-latest
steps:
- name: Trigger Dokploy rebuild
env:
WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }}
run: |
if [ -z "$WEBHOOK" ]; then
echo "::error::DOKPLOY_CDN_DEPLOY_WEBHOOK is required. Configure it or set RELEASE_LANE_CDN=disabled."
exit 1
fi
# The URL is itself the deploy credential, so never send it over a
# scheme that puts it on the wire in cleartext.
case "$WEBHOOK" in
https://*) ;;
*)
echo "::error::DOKPLOY_CDN_DEPLOY_WEBHOOK must use https://."
exit 1
;;
esac
# The webhook reads the branch from the body, but only when the
# request also carries `X-GitHub-Event`: Dokploy's extractBranchName
# returns null without that header, so the request answers
# 301 {"message":"Branch Not Match"} and deploys nothing.
#
# 301 is not an error status, so `--fail` does not see it and curl
# exits 0. Capture the status code and treat anything but 2xx as a
# failed deploy.
#
# A transient failure must never fail the workflow. npm has already
# published by now and that is irreversible, so dying here buys
# nothing — an earlier version of this job was deleted because a
# curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release
# polls the manifest and is the gate that fails loudly.
# On 0.18.0, one connect consumed the full 60-second budget. A short
# connect timeout turns the same wall-clock budget into more attempts
# during an outage instead of waiting on connections never made.
status=$(curl -sS -o /dev/stderr -w '%{http_code}' -X POST "$WEBHOOK" \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: push' \
-d '{"ref":"refs/heads/main"}' \
--connect-timeout 15 --max-time 45 --retry 5 --retry-all-errors --retry-delay 15) || status=000
case "$status" in
2*) echo "CDN redeploy triggered (HTTP $status)." ;;
*) echo "::warning::CDN redeploy webhook returned HTTP $status — verify-cdn-release will catch a stale CDN." ;;
esac
# Verifies that the published release is internally consistent and that the
# CDN caught up with npm. It polls, so it must run after redeploy-cdn.
#
# 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: 20
name: Verify release consistency
# Checkout only; verify-release-consistency.mjs uses no GitHub token.
permissions:
contents: read
needs:
- release
- redeploy-cdn
# Without `always()` a skip anywhere upstream skips this job too, and the
# gate that exists to catch a half-shipped release goes quiet in exactly
# the runs that need it. It stays out of `redeploy-cdn`'s result on
# purpose: a CDN that never redeployed is the failure this asserts, so it
# has to run and report it rather than disappear with it.
if: >-
always()
&& needs.release.result == 'success'
&& vars.RELEASE_LANE_CDN != 'disabled'
&& (needs.release.outputs.pythinker_native_release == 'true'
|| needs.release.outputs.cli_version_bumped == 'true')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
- name: Verify release consistency
env:
DOKPLOY_CDN_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }}
run: node scripts/release/verify-release-consistency.mjs
update-brew-tap:
timeout-minutes: 15
# Checkout only; the tap push uses a minted app token, not this one.
permissions:
contents: read
name: Update Homebrew tap
needs: release
if: >-
needs.release.outputs.pythinker_native_release == 'true'
&& vars.RELEASE_LANE_BREW != 'disabled'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
# Any permission-* input switches the token from inheriting every
# permission the App installation holds to exactly the ones listed here.
# Cloning and pushing the tap needs contents and nothing else.
- name: Mint tap token
id: tap-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2
with:
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: PyModel
repositories: homebrew-tap
permission-contents: write
- name: Bump formula
env:
TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
run: 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
# A called workflow cannot elevate the permissions granted here.
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
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
# One certificate, one notary key, one set of secret names. These are the
# names desktop-release.yml already ships signed and notarized from; the
# APPLE_CERTIFICATE_*/APPLE_NOTARIZATION_* names this workflow used to read
# were never set on this repository, which is why every darwin CLI bundle
# so far shipped ad-hoc signed behind a green job.
secrets:
APPLE_CERTIFICATE_P12: ${{ secrets.MAC_CSC_LINK }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER }}
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Download native artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pinned from 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"
# Unlike the desktop release, which uploads into a draft and refuses to
# touch a published one, this release is already live: changesets creates
# it when it publishes to npm. `--clobber` therefore replaced the assets
# of a shipped version on any re-run of this job — and a re-run after a
# source change would put different bytes behind a version number users
# already have. A version has to identify one exact build forever.
#
# These assets are one set, not a bag of files: manifest.json pins a
# sha256 for every zip, and install.sh / install.ps1 verify against it.
# So the rule is all or nothing. A release that already carries the whole
# set is finished and is left alone; an empty one gets everything. A
# partial set stops the job, because filling in the gaps would pair zips
# from one build with checksums from another, and a rebuild is not
# guaranteed to be byte-identical even at the same commit. Sorting that
# out is a decision for a person: either what is published is the
# release, or it needs a new patch version.
- name: Upload assets to GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }}
run: |
set -euo pipefail
existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')"
present=()
absent=()
for path in dist-native-release/*; do
name="$(basename "$path")"
if printf '%s\n' "$existing" | grep -qxF "$name"; then
present+=("$name")
else
absent+=("$path")
fi
done
if [ "${#absent[@]}" -eq 0 ]; then
echo "::notice::All ${#present[@]} assets are already on ${RELEASE_TAG}; nothing to upload."
exit 0
fi
if [ "${#present[@]}" -gt 0 ]; then
echo "::error::${RELEASE_TAG} already carries ${#present[@]} of these assets: ${present[*]}"
echo "::error::Adding the rest would mix two builds behind one version - manifest.json pins a sha256 per zip."
echo "::error::Resolve by hand: keep what is published, or cut the next patch and release that instead."
exit 1
fi
gh release upload "$RELEASE_TAG" "${absent[@]}"
release-summary:
name: Release lane summary
if: always() && github.repository_owner == 'PyModel'
needs:
- release
- cut-desktop-tag
- publish-vscode-extension
- native-artifacts
- publish-native-assets
- redeploy-cdn
- verify-cdn-release
- update-brew-tap
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
- name: Render and enforce lane summary
env:
RELEASE_RESULT: ${{ needs.release.result }}
CLI_EXPECTED: ${{ needs.release.outputs.cli_version_bumped == 'true' || needs.release.outputs.pythinker_native_release == 'true' }}
CLI_PUBLISHED: ${{ needs.release.outputs.pythinker_native_release }}
NATIVE_RESULT: ${{ needs.publish-native-assets.result }}
CDN_ENABLED: ${{ vars.RELEASE_LANE_CDN != 'disabled' }}
CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }}
CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }}
BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }}
BREW_RESULT: ${{ needs.update-brew-tap.result }}
DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }}
DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }}
DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }}
VSCODE_EXPECTED: ${{ needs.release.outputs.extension_version_bumped }}
VSCODE_ENABLED: ${{ vars.RELEASE_LANE_VSCODE != 'disabled' }}
VSCODE_RESULT: ${{ needs.publish-vscode-extension.result }}
run: node scripts/release/render-summary.mjs