diff --git a/.claude/hooks/check-changelog.sh b/.claude/hooks/check-changelog.sh new file mode 100755 index 00000000..06e96239 --- /dev/null +++ b/.claude/hooks/check-changelog.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Pre-hook: block `gh pr create` when shipped-code paths are changed but +# CHANGELOG.md has nothing under ## Unreleased. +# Mirrors the logic in .github/workflows/changelog-entry-required.yml so the +# gate fires locally before CI does. + +set -uo pipefail + +input=$(cat) +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""') + +# Only intercept gh pr create invocations. +if ! printf '%s' "$cmd" | grep -q 'gh pr create'; then + exit 0 +fi + +# Skip release-prep branches and titles (they consume ## Unreleased). +branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +case "$branch" in + release/*) exit 0 ;; +esac +if printf '%s' "$cmd" | grep -qF 'chore(release)'; then + exit 0 +fi +# [skip changelog] anywhere in the command body is also an escape hatch. +if printf '%s' "$cmd" | grep -qiF '[skip changelog]'; then + exit 0 +fi + +# Determine which files changed vs the merge-base with origin/main. +base=$(git merge-base HEAD origin/main 2>/dev/null || echo "") +if [ -z "$base" ]; then + # Can't determine base — don't block. + exit 0 +fi +changed=$(git diff --name-only "$base" HEAD 2>/dev/null || echo "") + +# Check for shipped-code paths (matches the CI workflow exactly). +touched=0 +while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + src/*|packages/*) touched=1; break ;; + scripts/install*.sh|scripts/install*.ps1) touched=1; break ;; + pythinker.spec) touched=1; break ;; + .github/workflows/linux-installer.yml|\ + .github/workflows/windows-installer.yml|\ + .github/workflows/homebrew-tap.yml|\ + .github/workflows/release-*.yml|\ + .github/workflows/promote-release.yml) touched=1; break ;; + esac +done <<< "$changed" + +[ "$touched" -eq 0 ] && exit 0 + +# Pass if ## Unreleased has at least one non-blank line. +if awk ' + /^## Unreleased[[:space:]]*$/ { inblk=1; next } + inblk && /^## / { inblk=0 } + inblk { print } +' CHANGELOG.md 2>/dev/null | grep -q '[^[:space:]]'; then + exit 0 +fi + +# Block and tell the author exactly what to do. +printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"CHANGELOG gate: this branch touches shipped code but ## Unreleased in CHANGELOG.md is empty.\n\nAdd a bullet under ## Unreleased before opening the PR, for example:\n - **Your change.** Brief description.\n\nEscape hatches:\n - Add [skip changelog] in the PR body\n - Use branch name release/* or title chore(release)*"}}' diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..fc5b99df --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/check-changelog.sh", + "timeout": 15, + "statusMessage": "Checking CHANGELOG gate..." + } + ] + } + ] + } +} diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 58b15668..4b1d1b10 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -4,9 +4,14 @@ name: Promote release # 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, PyPI, and the Homebrew formula, then clears `prerelease` and marks the -# release latest — the single point where a version becomes resolvable by the -# install scripts and in-app updater. +# 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 @@ -93,7 +98,6 @@ jobs: "pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz.sha256" ) pypi_url="https://pypi.org/pypi/pythinker-code/${version}/json" - homebrew_formula_url="https://raw.githubusercontent.com/Pythoughts-labs/homebrew-pythinker/main/Formula/pythinker-code.rb" # 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 @@ -101,6 +105,9 @@ jobs: # 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 )) @@ -120,15 +127,9 @@ jobs: pypi_ready=true fi - homebrew_ready=false - formula_text=$(curl -fsSL --retry 2 --retry-delay 2 "$homebrew_formula_url" 2>/dev/null || true) - if grep -qF "version \"${version}\"" <<<"$formula_text"; then - homebrew_ready=true - fi - - if [[ "${#missing_assets[@]}" -eq 0 && "$pypi_ready" == "true" && "$homebrew_ready" == "true" ]]; then + if [[ "${#missing_assets[@]}" -eq 0 && "$pypi_ready" == "true" ]]; then all_ready=true - echo "All install channels ready (attempt $i)" + echo "All required install channels ready (attempt $i)" break fi @@ -139,9 +140,6 @@ jobs: if [[ "$pypi_ready" != "true" ]]; then echo "PyPI is not serving ${version} yet: $pypi_url" fi - if [[ "$homebrew_ready" != "true" ]]; then - echo "Homebrew tap formula is not at ${version} yet: $homebrew_formula_url" - fi if [[ "$i" -lt "$max_attempts" ]]; then echo "Retrying in ${poll_interval}s..." sleep "$poll_interval" @@ -163,74 +161,124 @@ jobs: 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)." - - name: Mint GitHub App token for pythinker-home - id: app-token + # 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: - APP_ID: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }} - APP_PRIVATE_KEY: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }} - DISPATCH_OWNER: Pythoughts-labs - DISPATCH_REPO: pythinker-home + 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: | - set -euo pipefail - if [ -z "${APP_ID:-}" ] || [ -z "${APP_PRIVATE_KEY:-}" ]; then - echo "::error::Missing PYTHINKER_RELEASE_BOT_APP_ID or PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY. Configure the org-owned pythinker-release-bot App and install it on ${DISPATCH_OWNER}/${DISPATCH_REPO} with Contents: Read and write." >&2 - exit 1 + # 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 - b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } - key_file=$(mktemp) - trap 'rm -f "$key_file"' EXIT - printf '%s\n' "$APP_PRIVATE_KEY" > "$key_file" - chmod 600 "$key_file" - - now=$(date +%s) - header=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url) - payload=$(jq -nc --argjson iat "$((now - 60))" --argjson exp "$((now + 540))" --arg iss "$APP_ID" '{iat:$iat,exp:$exp,iss:$iss}' | b64url) - unsigned="${header}.${payload}" - signature=$(printf '%s' "$unsigned" | openssl dgst -sha256 -sign "$key_file" | b64url) - jwt="${unsigned}.${signature}" - - installation_id=$(curl --fail-with-body -sS \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${jwt}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/installation" \ - | jq -er '.id') - token=$(jq -nc --arg repo "$DISPATCH_REPO" '{repositories:[$repo],permissions:{contents:"write"}}' \ - | curl --fail-with-body -sS \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${jwt}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/app/installations/${installation_id}/access_tokens" \ - -d @- \ - | jq -er '.token') - echo "::add-mask::$token" - echo "token=$token" >> "$GITHUB_OUTPUT" + # 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 + - 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: | - set -euo pipefail - if [ -z "${DISPATCH_TOKEN:-}" ]; then - echo "::error::No dispatch token: the pythinker-release-bot App token mint produced an empty value. Confirm PYTHINKER_RELEASE_BOT_APP_ID and PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY org secrets are set and the App is installed on ${DISPATCH_OWNER}/${DISPATCH_REPO} with Contents: Read and write." >&2 - exit 1 + # 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" \ + + 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}}') - curl --fail-with-body \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $DISPATCH_TOKEN" \ - "https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \ - -d "$payload" + + 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 diff --git a/.gitignore b/.gitignore index f07232cd..d29b4d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,10 @@ node_modules/ static/ .memo/ .entire -.claude +.claude/* +!.claude/settings.json +!.claude/hooks/ +!.claude/hooks/** .pythinker/ .worktrees/ blackbox/ diff --git a/CHANGELOG.md b/CHANGELOG.md index acb15271..278553de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Release promotion no longer stalls when the Homebrew tap is broken.** The `promote-release` workflow now gates only on platform assets and PyPI; a lagging or broken Homebrew tap emits a warning annotation and step summary note but no longer blocks the GitHub Release from reaching Latest. + ## 0.30.0 (2026-06-02) ### What changed in this release