Skip to content

chore(release): prepare 0.61.0 (#232) #39

chore(release): prepare 0.61.0 (#232)

chore(release): prepare 0.61.0 (#232) #39

Workflow file for this run

name: Promote release
# Platform build workflows (windows-installer, linux-installer,
# release-pythinker-cli) create the GitHub Release as a PRERELEASE so it stays
# out of the date-based /releases/latest endpoint (which ignores make_latest)
# until every install channel is ready. This workflow waits for exact release
# assets and PyPI, then clears `prerelease` and marks the release latest — the
# single point where a version becomes resolvable by the install scripts and
# in-app updater.
#
# Homebrew is checked best-effort AFTER promotion: a broken tap (lost token,
# org migration, etc.) must not hold the GitHub Latest badge and install scripts
# hostage. The Homebrew check emits a warning annotation and step summary note
# so the gap is visible without blocking.
#
# It runs on the tag push (not `release: published`, which a GITHUB_TOKEN-created
# release never fires) so promotion always happens. workflow_dispatch allows a
# manual re-promote, e.g. after re-running a single platform build that
# re-marked the release as prerelease.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:
inputs:
tag:
description: "Release tag to promote (e.g. v0.27.0)"
required: true
type: string
# Serialize promotion per tag so a tag push and a manual re-promote can't race.
concurrency:
group: promote-release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
# Only this job mutates the release; notify-failure stays read-only.
permissions:
contents: write
steps:
- name: Resolve and validate tag
id: tag
env:
REF_NAME: ${{ github.ref_name }}
INPUT_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
tag="$INPUT_TAG"
else
tag="$REF_NAME"
fi
# Defend against injection: only ever act on a strict vMAJOR.MINOR.PATCH tag.
if ! printf '%s' "$tag" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Refusing to promote: invalid tag '$tag'"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Wait for install-channel readiness
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
version="${TAG#v}"
required_assets=(
"PythinkerSetup-${version}.exe"
"PythinkerSetup-${version}.exe.sha256"
"pythinker-code_${version}_amd64.deb"
"pythinker-code_${version}_amd64.deb.sha256"
"pythinker-code_${version}_arm64.deb"
"pythinker-code_${version}_arm64.deb.sha256"
"pythinker-code-${version}.x86_64.rpm"
"pythinker-code-${version}.x86_64.rpm.sha256"
"pythinker-code-${version}.aarch64.rpm"
"pythinker-code-${version}.aarch64.rpm.sha256"
"pythinker-${version}-x86_64-unknown-linux-gnu.tar.gz"
"pythinker-${version}-x86_64-unknown-linux-gnu.tar.gz.sha256"
"pythinker-${version}-aarch64-unknown-linux-gnu.tar.gz"
"pythinker-${version}-aarch64-unknown-linux-gnu.tar.gz.sha256"
"pythinker-${version}-aarch64-apple-darwin.tar.gz"
"pythinker-${version}-aarch64-apple-darwin.tar.gz.sha256"
"pythinker-${version}-x86_64-apple-darwin.tar.gz"
"pythinker-${version}-x86_64-apple-darwin.tar.gz.sha256"
"pythinker-${version}-x86_64-unknown-linux-gnu-onedir.tar.gz"
"pythinker-${version}-x86_64-unknown-linux-gnu-onedir.tar.gz.sha256"
"pythinker-${version}-aarch64-unknown-linux-gnu-onedir.tar.gz"
"pythinker-${version}-aarch64-unknown-linux-gnu-onedir.tar.gz.sha256"
"pythinker-${version}-aarch64-apple-darwin-onedir.tar.gz"
"pythinker-${version}-aarch64-apple-darwin-onedir.tar.gz.sha256"
"pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz"
"pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz.sha256"
)
pypi_url="https://pypi.org/pypi/pythinker-code/${version}/json"
# The budget must comfortably exceed the slowest platform build, since
# this job runs on the tag push in parallel with them. The long pole is
# linux-installer's emulated arm64 .deb/.rpm step: on the 0.26.0 release
# it finished at ~21m, 45s after the old 40x30s=20m budget had already
# timed out — leaving the release stuck as a prerelease. 80x30s=40m
# gives ~2x margin; a genuinely stuck build still surfaces as a failed
# build workflow, and workflow_dispatch allows a manual re-promote.
#
# Homebrew is NOT checked here — it is best-effort and checked after
# promotion so a broken tap never blocks the GitHub Latest badge.
max_attempts=80
poll_interval=30
budget_min=$(( max_attempts * poll_interval / 60 ))
echo "Polling install-channel readiness for $TAG (up to ${budget_min}m)..."
all_ready=false
for i in $(seq 1 "$max_attempts"); do
assets_json=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '[.assets[].name]' 2>/dev/null || printf '[]')
missing_assets=()
for asset in "${required_assets[@]}"; do
if ! jq -e --arg name "$asset" 'index($name)' <<<"$assets_json" >/dev/null; then
missing_assets+=("$asset")
fi
done
pypi_ready=false
if curl -fsSL --retry 2 --retry-delay 2 -o /dev/null "$pypi_url"; then
pypi_ready=true
fi
if [[ "${#missing_assets[@]}" -eq 0 && "$pypi_ready" == "true" ]]; then
all_ready=true
echo "All required install channels ready (attempt $i)"
break
fi
echo "Attempt $i/$max_attempts: install channels not ready."
if [[ "${#missing_assets[@]}" -gt 0 ]]; then
printf 'Missing release assets: %s\n' "${missing_assets[*]}"
fi
if [[ "$pypi_ready" != "true" ]]; then
echo "PyPI is not serving ${version} yet: $pypi_url"
fi
if [[ "$i" -lt "$max_attempts" ]]; then
echo "Retrying in ${poll_interval}s..."
sleep "$poll_interval"
fi
done
if [[ "$all_ready" != "true" ]]; then
echo "::error::Install channels were not fully ready after ${budget_min} minutes"
exit 1
fi
- name: Promote release (clear prerelease, mark latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
release_id=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')
gh api -X PATCH "repos/$REPO/releases/$release_id" -F prerelease=false -f make_latest=true
echo "Promoted $TAG: prerelease=false, make_latest=true (all platform assets present)."
# Best-effort Homebrew check. The release is ALREADY promoted above; a
# broken tap (lost token, org migration, etc.) must not re-block it.
# This step emits a warning annotation and step summary note so the gap
# is visible, then exits 0. The tap is repaired separately via
# HOMEBREW_TAP_TOKEN; this step just surfaces when it lags behind.
- name: Check Homebrew tap (best-effort)
env:
TAG: ${{ steps.tag.outputs.tag }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SOURCE_REPO: ${{ github.repository }}
run: |
# No `set -e`: this step must never fail the job.
set -uo pipefail
version="${TAG#v}"
homebrew_formula_url="https://raw.githubusercontent.com/Pythoughts-labs/homebrew-pythinker/main/Formula/pythinker-code.rb"
formula_text=$(curl -fsSL --retry 2 --retry-delay 2 "$homebrew_formula_url" 2>/dev/null || true)
if grep -qF "version \"${version}\"" <<<"$formula_text"; then
echo "Homebrew tap is at ${version}. ✓"
exit 0
fi
# Tap is behind — warn but do not fail.
reason="Homebrew tap formula is not yet at ${version}: ${homebrew_formula_url}"
echo "::warning title=Homebrew tap lagging::${reason}"
{
echo "### :warning: Homebrew tap is not yet at ${version} (non-blocking)"
echo ""
echo "${reason}"
echo ""
echo "**Non-blocking:** the GitHub Release has already been promoted to Latest."
echo "The tap updates automatically once \`HOMEBREW_TAP_TOKEN\` is valid and the"
echo "\`homebrew-tap\` workflow runs. To fix: restore the token secret and"
echo "re-run the \`homebrew-tap\` workflow for this tag via \`workflow_dispatch\`."
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
alert=$(jq -n --arg run_url "$RUN_URL" --arg repo "$SOURCE_REPO" \
--arg version "$version" --arg url "$homebrew_formula_url" \
'{"text":":warning: *Homebrew tap lagging (non-blocking)*","attachments":[{"color":"warning","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Expected version","value":$version,"short":true},{"title":"Formula URL","value":$url,"short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl -sS -X POST -H "Content-Type: application/json" -d "$alert" "$SLACK_WEBHOOK_URL" || true
fi
exit 0
# Best-effort website sync trigger. The release is ALREADY promoted above;
# this only accelerates the pythinker-home mirror, which also re-syncs on
# its own daily cron. So a missing/rotated App degrades gracefully here
# rather than failing an otherwise-successful promotion.
- name: Mint GitHub App token for pythinker-home
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2.2.2
with:
app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }}
owner: Pythoughts-labs
repositories: pythinker-home
permission-contents: write
- name: Trigger pythinker-home sync (best-effort)
env:
DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }}
TOKEN_OUTCOME: ${{ steps.app-token.outcome }}
SOURCE_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
DISPATCH_OWNER: Pythoughts-labs
DISPATCH_REPO: pythinker-home
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# No `set -e`: a failed website-sync trigger must not fail an already
# successful release promotion. The daily cron in pythinker-home is
# the real sync guarantee; here we warn + alert and exit 0.
set -uo pipefail
degrade() {
reason="$1"
echo "::warning title=pythinker-home sync skipped::${reason}"
{
echo "### :warning: pythinker-home sync dispatch skipped (release was still promoted)"
echo ""
echo "${reason}"
echo ""
echo "**Non-blocking:** pythinker-home re-syncs on its daily cron (\`sync-upstream-products\` @ 04:17 UTC), so the website is not stale."
echo ""
echo "**Restore the fast path:** recreate/install the **pythinker-release-bot** App on \`${DISPATCH_OWNER}\` with *Contents: write* on \`${DISPATCH_REPO}\`, then update the \`PYTHINKER_RELEASE_BOT_APP_ID\` and \`PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY\` secrets."
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
alert=$(jq -n --arg run_url "$RUN_URL" --arg repo "$SOURCE_REPO" --arg reason "$reason" \
'{"text":":warning: *pythinker-home sync dispatch skipped (non-blocking)*","attachments":[{"color":"warning","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Reason","value":$reason,"short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl -sS -X POST -H "Content-Type: application/json" -d "$alert" "$SLACK_WEBHOOK_URL" || true
fi
exit 0
}
if [ "${TOKEN_OUTCOME}" != "success" ] || [ -z "${DISPATCH_TOKEN:-}" ]; then
degrade "Could not mint a pythinker-release-bot App token (token step outcome: ${TOKEN_OUTCOME}). The App is likely missing/uninstalled on ${DISPATCH_OWNER}, or its credentials are stale."
fi
payload=$(jq -n --arg source_repo "$SOURCE_REPO" --arg tag "$RELEASE_TAG" \
'{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}')
resp=$(mktemp)
code="000"
for attempt in 1 2 3; do
code=$(curl -sS -o "$resp" -w '%{http_code}' \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DISPATCH_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \
-d "$payload") || code="000"
if [ "$code" = "204" ]; then
echo "Dispatched sync-pythinker-products to ${DISPATCH_OWNER}/${DISPATCH_REPO} (HTTP 204)."
exit 0
fi
echo "Dispatch attempt ${attempt} returned HTTP ${code}: $(head -c 200 "$resp")"
[ "$attempt" -lt 3 ] && sleep $((attempt * 3)) || true
done
degrade "repository_dispatch to ${DISPATCH_OWNER}/${DISPATCH_REPO} failed after 3 attempts (last HTTP ${code}): $(head -c 200 "$resp")"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: promote
if: failure()
permissions:
contents: read
steps:
- name: Post Slack alert
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO: ${{ github.repository }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -euo pipefail
if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then
exit 0
fi
payload=$(jq -n \
--arg run_url "$RUN_URL" \
--arg repo "$REPO" \
--arg tag "$TAG" \
'{"text":":red_circle: *Release promotion failed*","attachments":[{"color":"danger","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Tag","value":$tag,"short":true},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl --fail-with-body -X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"$SLACK_WEBHOOK_URL"