chore(release): prepare 0.61.0 (#232) #39
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: 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" |