Skip to content

ci: release packages (#177) #206

ci: release packages (#177)

ci: release packages (#177) #206

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 }}
extension_version_bumped: ${{ steps.extension-version.outputs.bumped }}
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@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
# Machine-readable signal for the VS Code extension publish gate: the
# release PR merge is a squash whose only meaningful delta for the
# extension is the version field, so compare it against the previous
# commit instead of trusting the commit-message prefix.
- name: Detect extension version bump
id: extension-version
run: |
prev=$(git show 'HEAD^:apps/vscode/package.json' | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')
curr=$(node -p 'require("./apps/vscode/package.json").version')
if [ "$prev" = "$curr" ]; then bumped=false; else bumped=true; fi
echo "bumped=$bumped" >> "$GITHUB_OUTPUT"
- 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.
# apps/desktop is a private workspace package: changesets bumps its
# version but nothing publishes it, and Desktop Release only fires on a
# `desktop-v*` tag. A bump that nobody tags therefore leaves every
# installed desktop client on the previous version with nothing red to
# show for it — which is exactly how 0.2.0 sat unreleased behind 0.1.6.
# Read both versions out of git rather than the working tree: the
# changesets action rewrites package.json in place on the run that opens
# the version PR, and that rewrite is not a release.
# The App token matters: a tag pushed with GITHUB_TOKEN would not start
# Desktop Release, because GitHub refuses to trigger workflows from it.
# A desktop tag that fails to cut costs one manual `git tag`; a failed step
# here would block npm, the Marketplace and the CDN behind it. Never let
# this be the thing that stops a release — but annotate every skip, since
# a silent no-op is the exact failure being fixed.
- name: Cut the desktop release tag on a version bump
continue-on-error: true
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
run: |
set -uo pipefail
# Read the pushed commit by sha, never HEAD. The changesets action
# checks out changeset-release/main and commits the bumped
# package.json files on the run that opens the version PR, so by the
# time this step runs HEAD can be a bump that is not a release — and
# tagging it would burn the next real version's tag name, which the
# already-exists check below would then treat as done.
read_version() { git show "$1:apps/desktop/package.json" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version' 2>/dev/null; }
prev="$(read_version "${GITHUB_SHA}^" || true)"
curr="$(read_version "${GITHUB_SHA}" || true)"
if [ -z "$prev" ] || [ -z "$curr" ] || [ "$prev" = "$curr" ]; then
echo "::notice::Desktop version unchanged or unreadable (prev='${prev}' curr='${curr}'); no tag cut."
exit 0
fi
tag="desktop-v${curr}"
# A tag on this commit is a re-run. A tag on a different commit is an
# anomaly that would otherwise silently swallow this release.
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 "::notice::${tag} already points here; nothing to do."
else
echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}. Desktop ${curr} will not ship until that is resolved."
fi
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}. Desktop ${curr} will not ship until someone pushes it: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}"
exit 1
fi
echo "Cut ${tag} at ${GITHUB_SHA} (desktop ${prev} -> ${curr})."
# 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 }}
# 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. Extension-only
# releases publish nothing to npm, so the gate also covers a version bump.
publish-vscode-extension:
timeout-minutes: 45
name: Publish VS Code extension
needs: release
if: >-
needs.release.outputs.packages_published == 'true'
|| needs.release.outputs.extension_version_bumped == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- 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"
- 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 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 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 run publish:ovsx
- name: Upload VSIX artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from 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). 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')
&& (needs.release.outputs.packages_published == 'true'
|| startsWith(github.event.head_commit.message, 'ci: release packages'))
runs-on: ubuntu-latest
steps:
- name: Trigger Dokploy rebuild
env:
WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }}
run: |
if [ -z "$WEBHOOK" ]; then
echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK not set — skipping CDN redeploy."
exit 0
fi
# The URL is itself the deploy credential, so never send it over a
# scheme that puts it on the wire in cleartext. Warn rather than fail:
# verify-cdn-release runs only if this job succeeds, and failing here
# would drop the consistency gate instead of tripping it.
case "$WEBHOOK" in
https://*) ;;
*)
echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK is not an https:// URL — refusing to send the deploy credential in cleartext."
exit 0
;;
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'
&& (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@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.packages_published == 'true'
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: |
# The token comes from the App installation, not a PAT.
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
# Matches the `contents: read` that _native-build.yml declares for itself;
# a called workflow cannot exceed what the calling job grants.
permissions:
contents: read
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@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[@]}"