Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions .github/workflows/dispatch-pythinker-home-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,46 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
DISPATCH_OWNER: TechMatrix-labs
DISPATCH_REPO: pythinker-home
steps:
# Mint a short-lived installation token for the org-owned
# pythinker-release-bot App (Contents: write on pythinker-home only).
# Replaces a personal PAT: org-owned (survives member/org changes),
# ~1h TTL, minted fresh each run, scoped to the single private site repo.
- name: Mint GitHub App token for pythinker-home
id: app-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }}
owner: ${{ env.DISPATCH_OWNER }}
repositories: ${{ env.DISPATCH_REPO }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
permission-contents: write

- name: Trigger pythinker-home sync
env:
DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }}
DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }}
SOURCE_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.sha }}
DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }}
DISPATCH_REPO: ${{ env.DISPATCH_REPO }}
run: |
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::notice::Skipping pythinker-home sync: PYTHINKER_HOME_REPO_DISPATCH_TOKEN is not configured"
exit 0
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
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}}')

curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DISPATCH_TOKEN" \
https://api.github.com/repos/TechMatrix-labs/pythinker-home/dispatches \
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \
-d "$payload"

notify-failure:
Expand Down
61 changes: 0 additions & 61 deletions .github/workflows/release-pythinker-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,64 +64,3 @@ jobs:
with:
packages-dir: dist/pythinker-core

docs:
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python 3.14
uses: actions/setup-python@v5
with:
python-version: "3.14"
allow-prereleases: true

- name: Set up uv
uses: astral-sh/setup-uv@v1
with:
version: "0.8.5"

- name: Install dependencies
run: uv sync --frozen --all-extras --project packages/pythinker-core

- name: Generate API documentation
run: |
VERSION="${GITHUB_REF_NAME#pythinker-core-}"
uv run --project packages/pythinker-core pdoc pythinker_core \
--docformat google \
--footer-text "pythinker-core ${VERSION}" \
-o packages/pythinker-core/docs

- name: Disable Jekyll processing
run: touch packages/pythinker-core/docs/.nojekyll

- name: Publish docs to gh-pages
env:
PYTHINKER_CORE_PAGES_TOKEN: ${{ secrets.PYTHINKER_CORE_PAGES_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${PYTHINKER_CORE_PAGES_TOKEN}" ]]; then
echo "PYTHINKER_CORE_PAGES_TOKEN is not set; skipping API documentation publish."
exit 0
fi

VERSION="${GITHUB_REF_NAME#pythinker-core-}"
PAGES_REPO="https://x-access-token:${PYTHINKER_CORE_PAGES_TOKEN}@github.com/PythinkerAI/pythinker-core.git"
PAGES_DIR="${RUNNER_TEMP}/pythinker-core-gh-pages"

git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

rm -rf "$PAGES_DIR"
git clone --depth 1 --branch gh-pages "$PAGES_REPO" "$PAGES_DIR"
rsync -a --delete --exclude '.git' "packages/pythinker-core/docs/" "$PAGES_DIR/"

git -C "$PAGES_DIR" add -A
if git -C "$PAGES_DIR" diff --cached --quiet; then
echo "No documentation changes to publish."
exit 0
fi

git -C "$PAGES_DIR" commit -m "docs: update for ${VERSION}"
git -C "$PAGES_DIR" push origin gh-pages
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- 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.
- **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.
- **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.
- **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.
Expand Down
78 changes: 54 additions & 24 deletions docs/public/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {

Write-Logo

function Test-ReleaseHasInstaller($release) {
if ($release.draft -or $release.prerelease) { return $null }
$tag = [string]$release.tag_name
if (-not $tag) { return $null }
$candidate = $tag.TrimStart('v')
$exe = "PythinkerSetup-$candidate.exe"
$names = @($release.assets | ForEach-Object { [string]$_.name })
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate }
return $null
}

function Format-ReleaseApiError($Uri, $ErrorRecord) {
$message = $ErrorRecord.Exception.Message
$status = $null
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
if ($status) { return "$Uri failed with HTTP ${status}: $message" }
return "$Uri failed: $message"
}

function Get-LatestVersion {
Step "Looking up latest Pythinker release"
# The GitHub Release is published before every platform asset finishes
# uploading, and the /releases/latest endpoint is date-based, so it can
# briefly advertise a version whose Windows installer is still in flight.
# Resolve the newest published (non-draft, non-prerelease) release that
# actually carries PythinkerSetup-<ver>.exe AND its .sha256, so a release
# caught mid-publish never 404s the download below.
$api = "https://api.github.com/repos/$Repo/releases?per_page=20"
try {
$releases = Invoke-RestMethod -UseBasicParsing -Uri $api
} catch {
Fail "could not fetch releases from $api"
}

foreach ($release in @($releases)) {
if ($release.draft -or $release.prerelease) { continue }
$tag = [string]$release.tag_name
if (-not $tag) { continue }
$candidate = $tag.TrimStart('v')
$exe = "PythinkerSetup-$candidate.exe"
$names = @($release.assets | ForEach-Object { [string]$_.name })
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) {
OK "Latest version is $candidate"
return $candidate
# /releases/latest is prerelease-excluding and not page-bound, so it is the
# correct primary source (fixes the per_page=20 pagination cliff). The
# GitHub Release can briefly advertise a version whose Windows installer is
# still uploading, so retry with exponential backoff (~6m). A paginated
# scan is only a fallback if /latest somehow lacks the asset pair.
$latestApi = "https://api.github.com/repos/$Repo/releases/latest"
$listApi = "https://api.github.com/repos/$Repo/releases?per_page=100"
$delay = 4
$elapsed = 0
$maxElapsed = 360
$lastApiError = $null
while ($true) {
try {
$latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi
$found = Test-ReleaseHasInstaller $latest
if ($found) { OK "Latest version is $found"; return $found }
} catch {
$lastApiError = Format-ReleaseApiError $latestApi $_
}
try {
$releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi
foreach ($release in @($releases)) {
$found = Test-ReleaseHasInstaller $release
if ($found) { OK "Latest version is $found"; return $found }
}
} catch {
$lastApiError = Format-ReleaseApiError $listApi $_
}
if ($elapsed -ge $maxElapsed) {
$detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" }
Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail"
}
Step "Windows installer asset not ready yet; retry in ${delay}s"
Start-Sleep -Seconds $delay
$elapsed += $delay
$delay = [Math]::Min($delay * 2, 120)
}
Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION"
}

function Read-ExpectedHash($Path) {
Expand Down
17 changes: 13 additions & 4 deletions docs/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,24 @@ release_has_assets() {
printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \
&& printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\""
}
# Exponential backoff: the GitHub Release can briefly advertise a version
# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total,
# before giving up — long enough to ride out a slow multi-arch upload.
attempt=0
delay=4
elapsed=0
max_elapsed=360
until release_has_assets; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 6 ]; then
fail "release assets for v${VERSION} are not available yet: ${tarball_url}
if [ "$elapsed" -ge "$max_elapsed" ]; then
fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url}
The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z"
fi
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)"
sleep 10
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)"
sleep "$delay"
elapsed=$((elapsed + delay))
delay=$((delay * 2))
[ "$delay" -gt 120 ] && delay=120
done

# --- download + verify --------------------------------------------------
Expand Down
17 changes: 13 additions & 4 deletions scripts/install-native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,24 @@ release_has_assets() {
printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \
&& printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\""
}
# Exponential backoff: the GitHub Release can briefly advertise a version
# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total,
# before giving up — long enough to ride out a slow multi-arch upload.
attempt=0
delay=4
elapsed=0
max_elapsed=360
until release_has_assets; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 6 ]; then
fail "release assets for v${VERSION} are not available yet: ${tarball_url}
if [ "$elapsed" -ge "$max_elapsed" ]; then
fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url}
The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z"
fi
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)"
sleep 10
step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)"
sleep "$delay"
elapsed=$((elapsed + delay))
delay=$((delay * 2))
[ "$delay" -gt 120 ] && delay=120
done

# --- download + verify --------------------------------------------------
Expand Down
78 changes: 54 additions & 24 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {

Write-Logo

function Test-ReleaseHasInstaller($release) {
if ($release.draft -or $release.prerelease) { return $null }
$tag = [string]$release.tag_name
if (-not $tag) { return $null }
$candidate = $tag.TrimStart('v')
$exe = "PythinkerSetup-$candidate.exe"
$names = @($release.assets | ForEach-Object { [string]$_.name })
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate }
return $null
}

function Format-ReleaseApiError($Uri, $ErrorRecord) {
$message = $ErrorRecord.Exception.Message
$status = $null
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
if ($status) { return "$Uri failed with HTTP ${status}: $message" }
return "$Uri failed: $message"
}

function Get-LatestVersion {
Step "Looking up latest Pythinker release"
# The GitHub Release is published before every platform asset finishes
# uploading, and the /releases/latest endpoint is date-based, so it can
# briefly advertise a version whose Windows installer is still in flight.
# Resolve the newest published (non-draft, non-prerelease) release that
# actually carries PythinkerSetup-<ver>.exe AND its .sha256, so a release
# caught mid-publish never 404s the download below.
$api = "https://api.github.com/repos/$Repo/releases?per_page=20"
try {
$releases = Invoke-RestMethod -UseBasicParsing -Uri $api
} catch {
Fail "could not fetch releases from $api"
}

foreach ($release in @($releases)) {
if ($release.draft -or $release.prerelease) { continue }
$tag = [string]$release.tag_name
if (-not $tag) { continue }
$candidate = $tag.TrimStart('v')
$exe = "PythinkerSetup-$candidate.exe"
$names = @($release.assets | ForEach-Object { [string]$_.name })
if (($names -contains $exe) -and ($names -contains "$exe.sha256")) {
OK "Latest version is $candidate"
return $candidate
# /releases/latest is prerelease-excluding and not page-bound, so it is the
# correct primary source (fixes the per_page=20 pagination cliff). The
# GitHub Release can briefly advertise a version whose Windows installer is
# still uploading, so retry with exponential backoff (~6m). A paginated
# scan is only a fallback if /latest somehow lacks the asset pair.
$latestApi = "https://api.github.com/repos/$Repo/releases/latest"
$listApi = "https://api.github.com/repos/$Repo/releases?per_page=100"
$delay = 4
$elapsed = 0
$maxElapsed = 360
$lastApiError = $null
while ($true) {
try {
$latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi
$found = Test-ReleaseHasInstaller $latest
if ($found) { OK "Latest version is $found"; return $found }
} catch {
$lastApiError = Format-ReleaseApiError $latestApi $_
}
try {
$releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi
foreach ($release in @($releases)) {
$found = Test-ReleaseHasInstaller $release
if ($found) { OK "Latest version is $found"; return $found }
}
} catch {
$lastApiError = Format-ReleaseApiError $listApi $_
}
if ($elapsed -ge $maxElapsed) {
$detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" }
Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail"
}
Step "Windows installer asset not ready yet; retry in ${delay}s"
Start-Sleep -Seconds $delay
$elapsed += $delay
$delay = [Math]::Min($delay * 2, 120)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION"
}

function Read-ExpectedHash($Path) {
Expand Down
17 changes: 16 additions & 1 deletion tests/test_release_update_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ def test_install_scripts_gate_on_asset_readiness() -> None:
waits for this version's archive + checksum before downloading.
"""
ps1 = (ROOT / "scripts" / "install.ps1").read_text()
assert "releases?per_page=" in ps1, "install.ps1 must scan releases, not trust /releases/latest"
assert "releases/latest" in ps1
assert "releases?per_page=100" in ps1
assert "Test-ReleaseHasInstaller" in ps1
assert "Format-ReleaseApiError" in ps1
assert "$release.prerelease" in ps1
assert '"$exe.sha256"' in ps1

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


def test_site_dispatch_uses_scoped_github_app_token_and_fails_loud() -> None:
workflow = (WORKFLOWS / "dispatch-pythinker-home-sync.yml").read_text()

assert "PYTHINKER_HOME_REPO_DISPATCH_TOKEN" not in workflow
assert "actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349" in workflow
assert "permission-contents: write" in workflow
assert "PYTHINKER_RELEASE_BOT_APP_ID" in workflow
assert "PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY" in workflow
assert "exit 1" in workflow
assert "Skipping pythinker-home sync" not in workflow


def test_windows_installer_signs_update_artifacts_when_credentials_are_available() -> None:
installer_script = (ROOT / "packages" / "windows-installer" / "installer.iss").read_text()
build_script = (ROOT / "packages" / "windows-installer" / "build.ps1").read_text()
Expand Down
Loading
Loading