Skip to content

Commit f0c080b

Browse files
authored
ci: dispatch App migration + retire dead docs step + installer hardening (#38)
* docs(changelog): note P0 dispatch + installer changes * ci(dispatch): mint pythinker-release-bot App token, fail loud on empty * ci(core): drop dead pdoc gh-pages publish (404 target) * fix(install): exponential backoff on native asset-wait * fix(install): /releases/latest-first + paginated fallback + backoff on Windows * test(release): guard App dispatch scope and installer mirrors
1 parent 9c3cb2b commit f0c080b

10 files changed

Lines changed: 243 additions & 153 deletions

File tree

.github/workflows/dispatch-pythinker-home-sync.yml

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,28 +22,46 @@ jobs:
2222
runs-on: ubuntu-latest
2323
permissions:
2424
contents: read
25+
env:
26+
DISPATCH_OWNER: TechMatrix-labs
27+
DISPATCH_REPO: pythinker-home
2528
steps:
29+
# Mint a short-lived installation token for the org-owned
30+
# pythinker-release-bot App (Contents: write on pythinker-home only).
31+
# Replaces a personal PAT: org-owned (survives member/org changes),
32+
# ~1h TTL, minted fresh each run, scoped to the single private site repo.
33+
- name: Mint GitHub App token for pythinker-home
34+
id: app-token
35+
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
36+
with:
37+
app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }}
38+
private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }}
39+
owner: ${{ env.DISPATCH_OWNER }}
40+
repositories: ${{ env.DISPATCH_REPO }}
41+
permission-contents: write
42+
2643
- name: Trigger pythinker-home sync
2744
env:
28-
DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }}
45+
DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }}
2946
SOURCE_REPO: ${{ github.repository }}
3047
RELEASE_TAG: ${{ github.sha }}
48+
DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }}
49+
DISPATCH_REPO: ${{ env.DISPATCH_REPO }}
3150
run: |
32-
if [ -z "$DISPATCH_TOKEN" ]; then
33-
echo "::notice::Skipping pythinker-home sync: PYTHINKER_HOME_REPO_DISPATCH_TOKEN is not configured"
34-
exit 0
51+
set -euo pipefail
52+
if [ -z "${DISPATCH_TOKEN:-}" ]; then
53+
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
54+
exit 1
3555
fi
36-
3756
payload=$(jq -n \
3857
--arg source_repo "$SOURCE_REPO" \
3958
--arg tag "$RELEASE_TAG" \
4059
'{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}')
41-
4260
curl --fail-with-body \
4361
-X POST \
4462
-H "Accept: application/vnd.github+json" \
4563
-H "Authorization: Bearer $DISPATCH_TOKEN" \
46-
https://api.github.com/repos/TechMatrix-labs/pythinker-home/dispatches \
64+
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \
4765
-d "$payload"
4866
4967
notify-failure:

.github/workflows/release-pythinker-core.yml

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -64,64 +64,3 @@ jobs:
6464
with:
6565
packages-dir: dist/pythinker-core
6666

67-
docs:
68-
runs-on: ubuntu-latest
69-
needs: validate
70-
steps:
71-
- name: Checkout repository
72-
uses: actions/checkout@v4
73-
74-
- name: Set up Python 3.14
75-
uses: actions/setup-python@v5
76-
with:
77-
python-version: "3.14"
78-
allow-prereleases: true
79-
80-
- name: Set up uv
81-
uses: astral-sh/setup-uv@v1
82-
with:
83-
version: "0.8.5"
84-
85-
- name: Install dependencies
86-
run: uv sync --frozen --all-extras --project packages/pythinker-core
87-
88-
- name: Generate API documentation
89-
run: |
90-
VERSION="${GITHUB_REF_NAME#pythinker-core-}"
91-
uv run --project packages/pythinker-core pdoc pythinker_core \
92-
--docformat google \
93-
--footer-text "pythinker-core ${VERSION}" \
94-
-o packages/pythinker-core/docs
95-
96-
- name: Disable Jekyll processing
97-
run: touch packages/pythinker-core/docs/.nojekyll
98-
99-
- name: Publish docs to gh-pages
100-
env:
101-
PYTHINKER_CORE_PAGES_TOKEN: ${{ secrets.PYTHINKER_CORE_PAGES_TOKEN }}
102-
run: |
103-
set -euo pipefail
104-
if [[ -z "${PYTHINKER_CORE_PAGES_TOKEN}" ]]; then
105-
echo "PYTHINKER_CORE_PAGES_TOKEN is not set; skipping API documentation publish."
106-
exit 0
107-
fi
108-
109-
VERSION="${GITHUB_REF_NAME#pythinker-core-}"
110-
PAGES_REPO="https://x-access-token:${PYTHINKER_CORE_PAGES_TOKEN}@github.com/PythinkerAI/pythinker-core.git"
111-
PAGES_DIR="${RUNNER_TEMP}/pythinker-core-gh-pages"
112-
113-
git config --global user.name "github-actions[bot]"
114-
git config --global user.email "github-actions[bot]@users.noreply.github.com"
115-
116-
rm -rf "$PAGES_DIR"
117-
git clone --depth 1 --branch gh-pages "$PAGES_REPO" "$PAGES_DIR"
118-
rsync -a --delete --exclude '.git' "packages/pythinker-core/docs/" "$PAGES_DIR/"
119-
120-
git -C "$PAGES_DIR" add -A
121-
if git -C "$PAGES_DIR" diff --cached --quiet; then
122-
echo "No documentation changes to publish."
123-
exit 0
124-
fi
125-
126-
git -C "$PAGES_DIR" commit -m "docs: update for ${VERSION}"
127-
git -C "$PAGES_DIR" push origin gh-pages

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- Release pipeline: migrate the pythinker-home website-sync dispatch to the org-owned `pythinker-release-bot` GitHub App and fail loud on an empty token; retire the dead pythinker-core API-docs gh-pages publish step; add exponential backoff to the native install scripts and fix the Windows installer's release-pagination cliff.
1819
- **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged.
1920
- **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals.
2021
- **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured.

docs/public/install.ps1

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
133133

134134
Write-Logo
135135

136+
function Test-ReleaseHasInstaller($release) {
137+
if ($release.draft -or $release.prerelease) { return $null }
138+
$tag = [string]$release.tag_name
139+
if (-not $tag) { return $null }
140+
$candidate = $tag.TrimStart('v')
141+
$exe = "PythinkerSetup-$candidate.exe"
142+
$names = @($release.assets | ForEach-Object { [string]$_.name })
143+
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate }
144+
return $null
145+
}
146+
147+
function Format-ReleaseApiError($Uri, $ErrorRecord) {
148+
$message = $ErrorRecord.Exception.Message
149+
$status = $null
150+
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
151+
if ($status) { return "$Uri failed with HTTP ${status}: $message" }
152+
return "$Uri failed: $message"
153+
}
154+
136155
function Get-LatestVersion {
137156
Step "Looking up latest Pythinker release"
138-
# The GitHub Release is published before every platform asset finishes
139-
# uploading, and the /releases/latest endpoint is date-based, so it can
140-
# briefly advertise a version whose Windows installer is still in flight.
141-
# Resolve the newest published (non-draft, non-prerelease) release that
142-
# actually carries PythinkerSetup-<ver>.exe AND its .sha256, so a release
143-
# caught mid-publish never 404s the download below.
144-
$api = "https://api.github.com/repos/$Repo/releases?per_page=20"
145-
try {
146-
$releases = Invoke-RestMethod -UseBasicParsing -Uri $api
147-
} catch {
148-
Fail "could not fetch releases from $api"
149-
}
150-
151-
foreach ($release in @($releases)) {
152-
if ($release.draft -or $release.prerelease) { continue }
153-
$tag = [string]$release.tag_name
154-
if (-not $tag) { continue }
155-
$candidate = $tag.TrimStart('v')
156-
$exe = "PythinkerSetup-$candidate.exe"
157-
$names = @($release.assets | ForEach-Object { [string]$_.name })
158-
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) {
159-
OK "Latest version is $candidate"
160-
return $candidate
157+
# /releases/latest is prerelease-excluding and not page-bound, so it is the
158+
# correct primary source (fixes the per_page=20 pagination cliff). The
159+
# GitHub Release can briefly advertise a version whose Windows installer is
160+
# still uploading, so retry with exponential backoff (~6m). A paginated
161+
# scan is only a fallback if /latest somehow lacks the asset pair.
162+
$latestApi = "https://api.github.com/repos/$Repo/releases/latest"
163+
$listApi = "https://api.github.com/repos/$Repo/releases?per_page=100"
164+
$delay = 4
165+
$elapsed = 0
166+
$maxElapsed = 360
167+
$lastApiError = $null
168+
while ($true) {
169+
try {
170+
$latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi
171+
$found = Test-ReleaseHasInstaller $latest
172+
if ($found) { OK "Latest version is $found"; return $found }
173+
} catch {
174+
$lastApiError = Format-ReleaseApiError $latestApi $_
175+
}
176+
try {
177+
$releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi
178+
foreach ($release in @($releases)) {
179+
$found = Test-ReleaseHasInstaller $release
180+
if ($found) { OK "Latest version is $found"; return $found }
181+
}
182+
} catch {
183+
$lastApiError = Format-ReleaseApiError $listApi $_
184+
}
185+
if ($elapsed -ge $maxElapsed) {
186+
$detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" }
187+
Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail"
161188
}
189+
Step "Windows installer asset not ready yet; retry in ${delay}s"
190+
Start-Sleep -Seconds $delay
191+
$elapsed += $delay
192+
$delay = [Math]::Min($delay * 2, 120)
162193
}
163-
Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION"
164194
}
165195

166196
function Read-ExpectedHash($Path) {

docs/public/install.sh

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,15 +226,24 @@ release_has_assets() {
226226
printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \
227227
&& printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\""
228228
}
229+
# Exponential backoff: the GitHub Release can briefly advertise a version
230+
# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total,
231+
# before giving up — long enough to ride out a slow multi-arch upload.
229232
attempt=0
233+
delay=4
234+
elapsed=0
235+
max_elapsed=360
230236
until release_has_assets; do
231237
attempt=$((attempt + 1))
232-
if [ "$attempt" -ge 6 ]; then
233-
fail "release assets for v${VERSION} are not available yet: ${tarball_url}
238+
if [ "$elapsed" -ge "$max_elapsed" ]; then
239+
fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url}
234240
The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z"
235241
fi
236-
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)"
237-
sleep 10
242+
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)"
243+
sleep "$delay"
244+
elapsed=$((elapsed + delay))
245+
delay=$((delay * 2))
246+
[ "$delay" -gt 120 ] && delay=120
238247
done
239248

240249
# --- download + verify --------------------------------------------------

scripts/install-native.sh

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,15 +226,24 @@ release_has_assets() {
226226
printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \
227227
&& printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\""
228228
}
229+
# Exponential backoff: the GitHub Release can briefly advertise a version
230+
# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total,
231+
# before giving up — long enough to ride out a slow multi-arch upload.
229232
attempt=0
233+
delay=4
234+
elapsed=0
235+
max_elapsed=360
230236
until release_has_assets; do
231237
attempt=$((attempt + 1))
232-
if [ "$attempt" -ge 6 ]; then
233-
fail "release assets for v${VERSION} are not available yet: ${tarball_url}
238+
if [ "$elapsed" -ge "$max_elapsed" ]; then
239+
fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url}
234240
The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z"
235241
fi
236-
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)"
237-
sleep 10
242+
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)"
243+
sleep "$delay"
244+
elapsed=$((elapsed + delay))
245+
delay=$((delay * 2))
246+
[ "$delay" -gt 120 ] && delay=120
238247
done
239248

240249
# --- download + verify --------------------------------------------------

scripts/install.ps1

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
133133

134134
Write-Logo
135135

136+
function Test-ReleaseHasInstaller($release) {
137+
if ($release.draft -or $release.prerelease) { return $null }
138+
$tag = [string]$release.tag_name
139+
if (-not $tag) { return $null }
140+
$candidate = $tag.TrimStart('v')
141+
$exe = "PythinkerSetup-$candidate.exe"
142+
$names = @($release.assets | ForEach-Object { [string]$_.name })
143+
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate }
144+
return $null
145+
}
146+
147+
function Format-ReleaseApiError($Uri, $ErrorRecord) {
148+
$message = $ErrorRecord.Exception.Message
149+
$status = $null
150+
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
151+
if ($status) { return "$Uri failed with HTTP ${status}: $message" }
152+
return "$Uri failed: $message"
153+
}
154+
136155
function Get-LatestVersion {
137156
Step "Looking up latest Pythinker release"
138-
# The GitHub Release is published before every platform asset finishes
139-
# uploading, and the /releases/latest endpoint is date-based, so it can
140-
# briefly advertise a version whose Windows installer is still in flight.
141-
# Resolve the newest published (non-draft, non-prerelease) release that
142-
# actually carries PythinkerSetup-<ver>.exe AND its .sha256, so a release
143-
# caught mid-publish never 404s the download below.
144-
$api = "https://api.github.com/repos/$Repo/releases?per_page=20"
145-
try {
146-
$releases = Invoke-RestMethod -UseBasicParsing -Uri $api
147-
} catch {
148-
Fail "could not fetch releases from $api"
149-
}
150-
151-
foreach ($release in @($releases)) {
152-
if ($release.draft -or $release.prerelease) { continue }
153-
$tag = [string]$release.tag_name
154-
if (-not $tag) { continue }
155-
$candidate = $tag.TrimStart('v')
156-
$exe = "PythinkerSetup-$candidate.exe"
157-
$names = @($release.assets | ForEach-Object { [string]$_.name })
158-
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) {
159-
OK "Latest version is $candidate"
160-
return $candidate
157+
# /releases/latest is prerelease-excluding and not page-bound, so it is the
158+
# correct primary source (fixes the per_page=20 pagination cliff). The
159+
# GitHub Release can briefly advertise a version whose Windows installer is
160+
# still uploading, so retry with exponential backoff (~6m). A paginated
161+
# scan is only a fallback if /latest somehow lacks the asset pair.
162+
$latestApi = "https://api.github.com/repos/$Repo/releases/latest"
163+
$listApi = "https://api.github.com/repos/$Repo/releases?per_page=100"
164+
$delay = 4
165+
$elapsed = 0
166+
$maxElapsed = 360
167+
$lastApiError = $null
168+
while ($true) {
169+
try {
170+
$latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi
171+
$found = Test-ReleaseHasInstaller $latest
172+
if ($found) { OK "Latest version is $found"; return $found }
173+
} catch {
174+
$lastApiError = Format-ReleaseApiError $latestApi $_
175+
}
176+
try {
177+
$releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi
178+
foreach ($release in @($releases)) {
179+
$found = Test-ReleaseHasInstaller $release
180+
if ($found) { OK "Latest version is $found"; return $found }
181+
}
182+
} catch {
183+
$lastApiError = Format-ReleaseApiError $listApi $_
184+
}
185+
if ($elapsed -ge $maxElapsed) {
186+
$detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" }
187+
Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail"
161188
}
189+
Step "Windows installer asset not ready yet; retry in ${delay}s"
190+
Start-Sleep -Seconds $delay
191+
$elapsed += $delay
192+
$delay = [Math]::Min($delay * 2, 120)
162193
}
163-
Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION"
164194
}
165195

166196
function Read-ExpectedHash($Path) {

tests/test_release_update_pipeline.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ def test_install_scripts_gate_on_asset_readiness() -> None:
5050
waits for this version's archive + checksum before downloading.
5151
"""
5252
ps1 = (ROOT / "scripts" / "install.ps1").read_text()
53-
assert "releases?per_page=" in ps1, "install.ps1 must scan releases, not trust /releases/latest"
53+
assert "releases/latest" in ps1
54+
assert "releases?per_page=100" in ps1
55+
assert "Test-ReleaseHasInstaller" in ps1
56+
assert "Format-ReleaseApiError" in ps1
5457
assert "$release.prerelease" in ps1
5558
assert '"$exe.sha256"' in ps1
5659

@@ -65,6 +68,18 @@ def test_install_scripts_gate_on_asset_readiness() -> None:
6568
assert (ROOT / "web" / "public" / "install.sh").read_text() == sh
6669

6770

71+
def test_site_dispatch_uses_scoped_github_app_token_and_fails_loud() -> None:
72+
workflow = (WORKFLOWS / "dispatch-pythinker-home-sync.yml").read_text()
73+
74+
assert "PYTHINKER_HOME_REPO_DISPATCH_TOKEN" not in workflow
75+
assert "actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349" in workflow
76+
assert "permission-contents: write" in workflow
77+
assert "PYTHINKER_RELEASE_BOT_APP_ID" in workflow
78+
assert "PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY" in workflow
79+
assert "exit 1" in workflow
80+
assert "Skipping pythinker-home sync" not in workflow
81+
82+
6883
def test_windows_installer_signs_update_artifacts_when_credentials_are_available() -> None:
6984
installer_script = (ROOT / "packages" / "windows-installer" / "installer.iss").read_text()
7085
build_script = (ROOT / "packages" / "windows-installer" / "build.ps1").read_text()

0 commit comments

Comments
 (0)