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
71 changes: 9 additions & 62 deletions .github/workflows/dispatch-pythinker-home-sync.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/linux-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
159 changes: 159 additions & 0 deletions .github/workflows/promote-release.yml
Original file line number Diff line number Diff line change
@@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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: |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
10 changes: 6 additions & 4 deletions .github/workflows/release-pythinker-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
13 changes: 7 additions & 6 deletions .github/workflows/windows-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
elkaix marked this conversation as resolved.
files: |
Expand Down
47 changes: 32 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**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.
Loading
Loading