diff --git a/.github/workflows/dispatch-pythinker-home-sync.yml b/.github/workflows/dispatch-pythinker-home-sync.yml index 376992e6..876b623b 100644 --- a/.github/workflows/dispatch-pythinker-home-sync.yml +++ b/.github/workflows/dispatch-pythinker-home-sync.yml @@ -1,86 +1,33 @@ name: Dispatch pythinker-home sync +# Triggers a pythinker-home website sync when the install scripts or README +# change on main. Release promotion and the post-release sync live in +# promote-release.yml: a GITHUB_TOKEN-created release never fires a workflow, +# so the old `release: published` trigger here was dead code that never ran the +# wait-for-assets / mark-latest steps. + on: workflow_dispatch: - release: - types: - - published push: branches: - main paths: - README.md - scripts/install.ps1 + - scripts/install-native.sh - .github/workflows/dispatch-pythinker-home-sync.yml jobs: dispatch: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - # When triggered by release: published, the linux-installer and - # windows-installer workflows are still uploading .deb/.rpm/.exe assets - # concurrently. Poll until all expected assets are present before - # dispatching so the pythinker-home sync doesn't fail on missing assets. - - name: Wait for all release assets - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - REPO: ${{ github.repository }} - run: | - required=( - "PythinkerSetup-" - "_amd64.deb" - "_arm64.deb" - ".x86_64.rpm" - ".aarch64.rpm" - "x86_64-unknown-linux-gnu.tar.gz" - "aarch64-unknown-linux-gnu.tar.gz" - "aarch64-apple-darwin.tar.gz" - "x86_64-apple-darwin.tar.gz" - ) - echo "Polling for all release assets on $TAG..." - all_present=false - for i in $(seq 1 40); do - assets=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '[.assets[].name] | join(" ")' 2>/dev/null || echo "") - all_present=true - for p in "${required[@]}"; do - if [[ "$assets" != *"$p"* ]]; then - all_present=false - break - fi - done - if [[ "$all_present" == "true" ]]; then - echo "All assets present (attempt $i)" - break - fi - echo "Attempt $i/40: assets not ready, retrying in 30s..." - sleep 30 - done - if [[ "$all_present" != "true" ]]; then - echo "::error::Release assets not fully uploaded after 20 minutes" - exit 1 - fi - - - name: Mark release latest after assets are ready - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - 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 make_latest=true - echo "Marked $TAG as the latest release after all update assets were present." - - name: Trigger pythinker-home sync env: DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }} SOURCE_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.event.release.tag_name || github.sha }} + RELEASE_TAG: ${{ github.sha }} run: | if [ -z "$DISPATCH_TOKEN" ]; then echo "::notice::Skipping pythinker-home sync: PYTHINKER_HOME_REPO_DISPATCH_TOKEN is not configured" diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index 23f691be..ff40752b 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -126,10 +126,11 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - # Never flip "latest" from here: if this job wins the create race, - # GitHub would default a new release to latest with only the Linux - # packages present. dispatch-pythinker-home-sync.yml marks the release - # latest only after every platform's assets are attached. + # Mark prerelease so this tag stays OUT of /releases/latest (which is + # date-based and ignores make_latest) if this job wins the create + # race with only the Linux packages present. promote-release.yml + # clears prerelease once every platform's assets are attached. + prerelease: "true" fail_on_unmatched_files: false make_latest: "false" files: | diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml new file mode 100644 index 00000000..c55ae169 --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -0,0 +1,159 @@ +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 asset has uploaded. This workflow waits for all expected assets, +# then clears `prerelease` and marks the release latest — the single point +# where a version becomes resolvable by the install scripts and in-app updater. +# +# 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.25.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 all release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + required=( + "PythinkerSetup-" + "_amd64.deb" + "_arm64.deb" + ".x86_64.rpm" + ".aarch64.rpm" + "x86_64-unknown-linux-gnu.tar.gz" + "aarch64-unknown-linux-gnu.tar.gz" + "aarch64-apple-darwin.tar.gz" + "x86_64-apple-darwin.tar.gz" + ) + echo "Polling for all release assets on $TAG..." + all_present=false + for i in $(seq 1 40); do + assets=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '[.assets[].name] | join(" ")' 2>/dev/null || echo "") + all_present=true + for p in "${required[@]}"; do + if [[ "$assets" != *"$p"* ]]; then + all_present=false + break + fi + done + if [[ "$all_present" == "true" ]]; then + echo "All assets present (attempt $i)" + break + fi + echo "Attempt $i/40: assets not ready, retrying in 30s..." + sleep 30 + done + if [[ "$all_present" != "true" ]]; then + echo "::error::Release assets not fully uploaded after 20 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)." + + - name: Trigger pythinker-home sync + env: + DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }} + SOURCE_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: | + set -euo pipefail + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::notice::Skipping pythinker-home sync: PYTHINKER_HOME_REPO_DISPATCH_TOKEN is not configured" + exit 0 + 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 \ + -d "$payload" + + 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" diff --git a/.github/workflows/release-pythinker-cli.yml b/.github/workflows/release-pythinker-cli.yml index a9fd85c2..4dfe7b22 100644 --- a/.github/workflows/release-pythinker-cli.yml +++ b/.github/workflows/release-pythinker-cli.yml @@ -544,10 +544,12 @@ jobs: name: ${{ github.ref_name }} generate_release_notes: true # Tolerate missing matrix targets so the Release still publishes - # when (e.g.) a single Windows runner crashed mid-build). Keep the - # release out of /releases/latest until the native installer workflows - # have attached their assets; dispatch-pythinker-home-sync.yml marks it - # latest only after the expected macOS/Linux/Windows update assets exist. + # when (e.g.) a single Windows runner crashed mid-build). Mark it + # prerelease so it stays out of /releases/latest (which is date-based + # and ignores make_latest) until every platform asset is attached; + # promote-release.yml clears prerelease once the expected + # macOS/Linux/Windows update assets exist. + prerelease: "true" fail_on_unmatched_files: false make_latest: "false" files: | diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 350b6e53..0354d4f0 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -92,12 +92,13 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - # All release workflows run on this tag and may race to create the - # Release. Keep make_latest=false: if this job wins the create race, - # softprops omits make_latest and GitHub would default a brand-new - # release to "latest" with only the Windows asset present, 404ing the - # curl installer's .tar.gz. dispatch-pythinker-home-sync.yml flips - # make_latest=true only after every platform's assets are attached. + # All platform workflows race to create this Release on the same tag. + # Mark it prerelease so it stays OUT of /releases/latest (which is + # date-based and ignores make_latest) until every platform asset has + # uploaded. promote-release.yml clears prerelease and sets make_latest + # once all expected assets are attached, so the install scripts and + # the in-app updater never resolve a version whose asset is in flight. + prerelease: "true" fail_on_unmatched_files: false make_latest: "false" files: | diff --git a/AGENTS.md b/AGENTS.md index 98492b77..3c081be6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -357,13 +357,13 @@ Never add AI-generated/co-author trailers or tool footers to commits or PR descr ## Versioning -The project follows a **minor-bump-only** versioning scheme (`MAJOR.MINOR.PATCH`): +The project follows a `0.MINOR.PATCH` versioning scheme: -- Patch version is always `0`. Never bump it. -- Minor version is bumped for any change: features, improvements, bug fixes, etc. -- Major version changes only by explicit manual decision. +- Major version stays `0`; there is no `1.0.0` milestone planned on this line. +- Minor version is a running counter, bumped for every release: features, improvements, bug fixes, etc. +- Patch version is reserved for hotfixes against an already-released minor; it is normally `0`. -Examples: `1.0.0` -> `1.1.0` -> `1.2.0`; never `1.0.1`. +Examples: `0.24.0` -> `0.25.0` -> `0.26.0`; a hotfix against `0.25.0` would be `0.25.1`. This applies to release packages in the root project and `packages/*` unless a release task targets an independently versioned package. Do not normalize `sdks/*` or `examples/*` versions unless the @@ -372,13 +372,30 @@ user or release workflow explicitly asks for that package. ## Release workflow 1. Ensure `main` is up to date. -2. Create a release branch, e.g. `bump-1.42` or `bump-pythinker-host-1.43`. -3. Update `CHANGELOG.md`: rename `[Unreleased]` to `[1.42] - YYYY-MM-DD`. -4. Update `pyproject.toml` version. -5. Run `uv sync` to align `uv.lock`. -6. Commit the branch and open a PR. -7. Merge the PR, then switch back to `main` and pull latest. -8. Tag and push: - - `git tag 1.42` or `git tag pythinker-host-1.43` - - `git push --tags` -9. GitHub Actions handles publishing after tags are pushed. +2. Create a release branch, e.g. `release/0.25.0`. +3. Update `CHANGELOG.md`: move the `## Unreleased` entries into a new + `## X.Y.Z (YYYY-MM-DD)` section and leave `## Unreleased` in place (emptied). + Then regenerate the docs copy with `npm run sync` from `docs/` — do not edit + `docs/en/release-notes/changelog.md` by hand — add a `## X.Y.Z (date)` entry to + `docs/en/release-notes/breaking-changes.md`, and update the README "What's New" + section plus the version strings across the install scripts and packaging files. +4. Update `pyproject.toml` version and run `uv sync` to align `uv.lock`. +5. Commit the branch and open a PR. `main` is protected, so the required checks + (and CodeRabbit) must pass before the PR can be squash-merged. +6. After merge, switch back to `main` and pull latest. +7. Tag the merged commit and push: + - `git tag -a v0.25.0 -m "pythinker-code 0.25.0"` + - `git push origin v0.25.0` +8. GitHub Actions (`release-pythinker-cli.yml`) publishes to PyPI/TestPyPI and the + GitHub Release after the tag is pushed. + + **Release asset coordination.** `/releases/latest` is date-based and ignores + `make_latest`, so each platform builder (`release-pythinker-cli.yml`, + `linux-installer.yml`, `windows-installer.yml`) creates/updates the Release + with `prerelease: "true"` to keep it out of `/releases/latest` while the + platforms upload concurrently. After the tag is pushed, `promote-release.yml` + polls until all 9 platform asset fragments are present, then atomically clears + `prerelease` and sets `make_latest=true` — the single point a version becomes + resolvable by the install scripts and the in-app updater. If a builder is + re-run after promotion it flips the Release back to prerelease; recover by + running `promote-release.yml` via `workflow_dispatch` for that tag. diff --git a/docs/public/install.ps1 b/docs/public/install.ps1 index 3db6c3e1..20c6b121 100644 --- a/docs/public/install.ps1 +++ b/docs/public/install.ps1 @@ -135,18 +135,32 @@ Write-Logo function Get-LatestVersion { Step "Looking up latest Pythinker release" - $api = "https://api.github.com/repos/$Repo/releases/latest" + # 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-.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 { - $release = Invoke-RestMethod -UseBasicParsing -Uri $api + $releases = Invoke-RestMethod -UseBasicParsing -Uri $api } catch { - Fail "could not fetch latest release from $api" + Fail "could not fetch releases from $api" } - $tag = [string]$release.tag_name - if (-not $tag) { Fail "latest release response did not include tag_name" } - $latest = $tag.TrimStart('v') - OK "Latest version is $latest" - return $latest + 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 + } + } + Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION" } function Read-ExpectedHash($Path) { diff --git a/docs/public/install.sh b/docs/public/install.sh index b3842dda..9de6a4dc 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -210,6 +210,33 @@ tarball="pythinker-${VERSION}-${target}.tar.gz" tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" sha_url="${tarball_url}.sha256" +# --- wait for assets to finish publishing ------------------------------- +# The GitHub Release is published before every platform asset finishes +# uploading, and /releases/latest is date-based, so it can briefly advertise a +# version whose archive is still in flight. Confirm this version's archive and +# checksum are attached (via the GitHub API, like the in-app updater) before +# downloading, so a release caught mid-publish does not 404. +release_has_assets() { + _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" + if command -v curl >/dev/null 2>&1; then + _body="$(curl -fsSL "$_api" 2>/dev/null)" || return 1 + else + _body="$(wget -qO- "$_api" 2>/dev/null)" || return 1 + fi + printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ + && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" +} +attempt=0 +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} +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 +done + # --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" trap 'rm -rf "$tmpdir"' EXIT diff --git a/docs/scripts/sync-changelog.mjs b/docs/scripts/sync-changelog.mjs index f7c434d1..2e5e865d 100644 --- a/docs/scripts/sync-changelog.mjs +++ b/docs/scripts/sync-changelog.mjs @@ -34,14 +34,9 @@ content = content.replace(/\n*/g, ""); // Remove the "# Changelog" title (we'll add our own header) content = content.replace(/^# Changelog\n+/, ""); -// Convert title format: ## [0.69] - 2025-12-29 -> ## 0.69 (2025-12-29) -content = content.replace( - /^## \[([^\]]+)\] - (\d{4}-\d{1,2}-\d{1,2})/gm, - "## $1 ($2)" -); - -// Remove subsection headers like ### Added, ### Changed, ### Fixed -content = content.replace(/^### (Added|Changed|Fixed|Improved|Tools|SDK)\n+/gm, ""); +// Release headers (`## X.Y.Z (YYYY-MM-DD)`) and the `### What changed in this +// release` subsections are copied through verbatim — the root CHANGELOG already +// uses the format the docs site renders, so no title rewriting is needed. // The docs changelog is emitted under docs/en/release-notes/, so links that are // correct from the repository root need to be adjusted for VitePress dead-link diff --git a/scripts/install-native.sh b/scripts/install-native.sh index b3842dda..9de6a4dc 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -210,6 +210,33 @@ tarball="pythinker-${VERSION}-${target}.tar.gz" tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" sha_url="${tarball_url}.sha256" +# --- wait for assets to finish publishing ------------------------------- +# The GitHub Release is published before every platform asset finishes +# uploading, and /releases/latest is date-based, so it can briefly advertise a +# version whose archive is still in flight. Confirm this version's archive and +# checksum are attached (via the GitHub API, like the in-app updater) before +# downloading, so a release caught mid-publish does not 404. +release_has_assets() { + _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" + if command -v curl >/dev/null 2>&1; then + _body="$(curl -fsSL "$_api" 2>/dev/null)" || return 1 + else + _body="$(wget -qO- "$_api" 2>/dev/null)" || return 1 + fi + printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ + && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" +} +attempt=0 +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} +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 +done + # --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" trap 'rm -rf "$tmpdir"' EXIT diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3db6c3e1..20c6b121 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -135,18 +135,32 @@ Write-Logo function Get-LatestVersion { Step "Looking up latest Pythinker release" - $api = "https://api.github.com/repos/$Repo/releases/latest" + # 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-.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 { - $release = Invoke-RestMethod -UseBasicParsing -Uri $api + $releases = Invoke-RestMethod -UseBasicParsing -Uri $api } catch { - Fail "could not fetch latest release from $api" + Fail "could not fetch releases from $api" } - $tag = [string]$release.tag_name - if (-not $tag) { Fail "latest release response did not include tag_name" } - $latest = $tag.TrimStart('v') - OK "Latest version is $latest" - return $latest + 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 + } + } + Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION" } function Read-ExpectedHash($Path) { diff --git a/tests/test_release_update_pipeline.py b/tests/test_release_update_pipeline.py index e72b12df..131c170c 100644 --- a/tests/test_release_update_pipeline.py +++ b/tests/test_release_update_pipeline.py @@ -3,26 +3,69 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +# Platform builders that create-or-update the GitHub Release on the tag push. +BUILDER_WORKFLOWS = ( + "windows-installer.yml", + "linux-installer.yml", + "release-pythinker-cli.yml", +) -def test_release_is_marked_latest_only_after_update_assets_are_ready() -> None: - release_workflow = (ROOT / ".github" / "workflows" / "release-pythinker-cli.yml").read_text() - dispatch_workflow = ( - ROOT / ".github" / "workflows" / "dispatch-pythinker-home-sync.yml" - ).read_text() - assert 'make_latest: "false"' in release_workflow - assert "Mark release latest after assets are ready" in dispatch_workflow - assert "-f make_latest=true" in dispatch_workflow - assert dispatch_workflow.index("Wait for all release assets") < dispatch_workflow.index( - "Mark release latest after assets are ready" +def test_builders_create_release_as_prerelease() -> None: + """Each builder must mark the Release prerelease. + + /releases/latest is date-based and ignores make_latest, so prerelease is + the only flag that keeps an in-progress release (whichever builder wins the + create race) out of the endpoint that install scripts and the in-app + updater resolve. promote-release.yml clears it once every asset is present. + """ + for name in BUILDER_WORKFLOWS: + workflow = (WORKFLOWS / name).read_text() + assert 'prerelease: "true"' in workflow, f"{name} must mark the release prerelease" + + +def test_release_is_promoted_only_after_update_assets_are_ready() -> None: + promote_workflow = (WORKFLOWS / "promote-release.yml").read_text() + + # Runs on the tag push (not `release: published`, which a GITHUB_TOKEN + # release never fires) so promotion always happens. + assert "tags:" in promote_workflow + + # Promotion clears prerelease AND marks latest, and only after the wait. + assert "-F prerelease=false" in promote_workflow + assert "-f make_latest=true" in promote_workflow + assert promote_workflow.index("Wait for all release assets") < promote_workflow.index( + "-F prerelease=false" ) +def test_install_scripts_gate_on_asset_readiness() -> None: + """The bootstrap installers must not 404 on a release caught mid-publish. + + install.ps1 resolves the newest release that actually carries the Windows + installer + its .sha256 (skipping draft/prerelease), and install-native.sh + 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 "$release.prerelease" in ps1 + assert '"$exe.sha256"' in ps1 + + sh = (ROOT / "scripts" / "install-native.sh").read_text() + assert "release_has_assets" in sh + assert "${tarball}.sha256" in sh + + # The three served copies must match their canonical source byte-for-byte. + assert (ROOT / "docs" / "public" / "install.ps1").read_text() == ps1 + assert (ROOT / "web" / "public" / "install.ps1").read_text() == ps1 + assert (ROOT / "docs" / "public" / "install.sh").read_text() == sh + assert (ROOT / "web" / "public" / "install.sh").read_text() == sh + + def test_release_asset_wait_covers_all_updater_channels() -> None: - dispatch_workflow = ( - ROOT / ".github" / "workflows" / "dispatch-pythinker-home-sync.yml" - ).read_text() + promote_workflow = (WORKFLOWS / "promote-release.yml").read_text() for expected_asset_fragment in ( "PythinkerSetup-", @@ -35,4 +78,4 @@ def test_release_asset_wait_covers_all_updater_channels() -> None: "aarch64-apple-darwin.tar.gz", "x86_64-apple-darwin.tar.gz", ): - assert expected_asset_fragment in dispatch_workflow + assert expected_asset_fragment in promote_workflow diff --git a/web/public/install.ps1 b/web/public/install.ps1 index 3db6c3e1..20c6b121 100644 --- a/web/public/install.ps1 +++ b/web/public/install.ps1 @@ -135,18 +135,32 @@ Write-Logo function Get-LatestVersion { Step "Looking up latest Pythinker release" - $api = "https://api.github.com/repos/$Repo/releases/latest" + # 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-.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 { - $release = Invoke-RestMethod -UseBasicParsing -Uri $api + $releases = Invoke-RestMethod -UseBasicParsing -Uri $api } catch { - Fail "could not fetch latest release from $api" + Fail "could not fetch releases from $api" } - $tag = [string]$release.tag_name - if (-not $tag) { Fail "latest release response did not include tag_name" } - $latest = $tag.TrimStart('v') - OK "Latest version is $latest" - return $latest + 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 + } + } + Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION" } function Read-ExpectedHash($Path) { diff --git a/web/public/install.sh b/web/public/install.sh index b3842dda..9de6a4dc 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -210,6 +210,33 @@ tarball="pythinker-${VERSION}-${target}.tar.gz" tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" sha_url="${tarball_url}.sha256" +# --- wait for assets to finish publishing ------------------------------- +# The GitHub Release is published before every platform asset finishes +# uploading, and /releases/latest is date-based, so it can briefly advertise a +# version whose archive is still in flight. Confirm this version's archive and +# checksum are attached (via the GitHub API, like the in-app updater) before +# downloading, so a release caught mid-publish does not 404. +release_has_assets() { + _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" + if command -v curl >/dev/null 2>&1; then + _body="$(curl -fsSL "$_api" 2>/dev/null)" || return 1 + else + _body="$(wget -qO- "$_api" 2>/dev/null)" || return 1 + fi + printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ + && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" +} +attempt=0 +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} +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 +done + # --- download + verify -------------------------------------------------- tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" trap 'rm -rf "$tmpdir"' EXIT