From 7b1914eafa25d2a1e19f4717a81ad7ccc958a5cb Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 19:02:26 -0400 Subject: [PATCH 1/2] ci: hold the line on semver and freeze published release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related changes to how a version is chosen and what it then means. The bump table now maps the commit type onto the level — fix, perf and security to patch, feat to minor, a break to major — and says plainly that no number rolls over at nine: ten fixes on 1.2.0 land on 1.2.10, and the major moves only through the approval gate, never by counting. Hosted-provider removal drops from major to minor. The next release is therefore 1.3.0 rather than 2.0.0, keeping the stable 1.x line. Published assets are now frozen in CI, not just in prose. The native CLI job uploaded with `--clobber` onto a release that changesets had already published, so any re-run replaced the binaries of a shipped version — and a re-run after a source change would have put different bytes behind a number users already had. It now uploads only what is missing: a run that failed part way through still completes, and a rebuild of a published version is left on the floor. The desktop release already worked this way, by uploading into a draft and refusing to touch a published release. --- .changeset/remove-built-in-hosted-provider.md | 2 +- .github/workflows/release.yml | 34 +++++++++++++++- CONTRIBUTING.md | 40 +++++++++++++++---- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.changeset/remove-built-in-hosted-provider.md b/.changeset/remove-built-in-hosted-provider.md index de11b0d9..9de205e1 100644 --- a/.changeset/remove-built-in-hosted-provider.md +++ b/.changeset/remove-built-in-hosted-provider.md @@ -1,5 +1,5 @@ --- -"@pymodel/pythinker-code": major +"@pymodel/pythinker-code": minor --- Remove the built-in hosted provider and its sign-in, usage, feedback, model aliases, and SDK methods; configure a supported provider with its own API key or OAuth instead. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f6dfa0c..2bdac474 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -643,8 +643,40 @@ jobs: RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }} run: node apps/pythinker-code/scripts/native/produce-manifest.mjs dist-native-release "$RELEASE_TAG" + # Unlike the desktop release, which uploads into a draft and refuses to + # touch a published one, this release is already live: changesets creates + # it when it publishes to npm. `--clobber` therefore replaced the assets + # of a shipped version on any re-run of this job — and a re-run after a + # source change would put different bytes behind a version number users + # already have. A version has to identify one exact build forever. + # + # Uploading only what is missing keeps both properties: a run that failed + # part way through still completes on a re-run, and a rebuild of an + # already-published version is left on the floor instead of shipped under + # its old number. To correct a bad build, cut the next patch. - name: Upload assets to GitHub Release + shell: bash env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }} - run: gh release upload "$RELEASE_TAG" dist-native-release/* --clobber + run: | + set -euo pipefail + existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')" + missing=() + skipped=() + for path in dist-native-release/*; do + name="$(basename "$path")" + if printf '%s\n' "$existing" | grep -qxF "$name"; then + skipped+=("$name") + else + missing+=("$path") + fi + done + if [ "${#skipped[@]}" -gt 0 ]; then + echo "::notice::Already on ${RELEASE_TAG} and left untouched: ${skipped[*]}" + fi + if [ "${#missing[@]}" -eq 0 ]; then + echo "::notice::Every asset is already on ${RELEASE_TAG}; nothing to upload." + exit 0 + fi + gh release upload "$RELEASE_TAG" "${missing[@]}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72a4d638..dee8af7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,21 +82,47 @@ This repo uses [changesets](https://github.com/changesets/changesets) to manage ### Bump levels -| Level | Use for | Example | -| --- | --- | --- | -| `patch` | A fix, or a small addition to something that already exists | `2.1.2` → `2.1.3` | -| `minor` | A capability a user could not reach before | `2.1.3` → `2.2.0` | -| `major` | A break: something that worked stops working, or works differently | `2.2.0` → `3.0.0` | +Pythinker Code follows semantic versioning. The commit type you would have written decides the level: + +| Commit type | Level | Use for | Example | +| --- | --- | --- | --- | +| `fix`, `perf`, `security` | `patch` | A fix, a speed-up, or a small addition to something that already exists | `1.2.0` → `1.2.1` | +| `feat` | `minor` | A capability a user could not reach before | `1.2.8` → `1.3.0` | +| a break | `major` | Something that worked stops working, or works differently | `1.x` → `2.0.0` | + +Each number counts on its own and none of them roll over at nine. Ten fixes on top of `1.2.0` land on `1.2.10`, not `1.3.0`; the minor moves only when a feature ships, and the major only when something breaks: + +``` +1.2.9 + fix → 1.2.10 +1.9.9 + fix → 1.9.10 +1.9.9 + feature → 1.10.0 +1.x + break → 2.0.0 +``` Prefer one changeset per pull request. A pull request that needs several is usually carrying several separate releases, and once they are versioned together the changelog can no longer say which change each entry came from. -A `major` needs a maintainer's sign-off: the `changeset-policy` workflow fails a pull request that adds a major changeset, or edits an existing one up to `major`, unless it carries the `breaking-change-approved` label. A pinned install keeps working, but every consumer who upgrades has to deal with the break, and an npm publish cannot be taken back — so it is a decision, never a side effect of a large branch. +A `major` needs a maintainer's sign-off: the `changeset-policy` workflow fails a pull request that adds a major changeset, or edits an existing one up to `major`, unless it carries the `breaking-change-approved` label. A pinned install keeps working, but every consumer who upgrades has to deal with the break, and an npm publish cannot be taken back — so it is a decision, never a side effect of a large branch. Nothing else can reach a major: no amount of counting gets there on its own. + +### A released version is one exact build, forever + +Once a version exists, everything published under it is frozen. This matters most for the desktop app and the native CLI bundles, where a version number is what an updater resolves to a specific set of bytes it has already advertised. + +Never do any of these: + +- Overwrite the assets on a published release +- Move a tag that has shipped +- Rebuild binaries and publish them under a version that already exists +- Replace `latest.yml` / `latest-mac.yml` / `manifest.json` for a version whose binaries are already out + +To correct a bad build, cut the next patch instead: fix the problem, bump, build from that exact commit, sign and notarize, and publish fresh artifacts under the new number. The updater then advertises the new version, and anyone who already installed the old one keeps a build that still matches what it claims to be. + +CI enforces this rather than trusting the rule. The desktop release uploads into a draft and refuses to touch a release that is already published; the native CLI job uploads only assets that are missing, so a run that failed part way through still completes while a rebuild of a shipped version is left on the floor. ### Release cadence Changesets keeps a `ci: release packages` pull request open on `main` and rewrites it as changesets land. Merging it cuts exactly one release, so how often it is merged is what decides the version sequence: -- Merged per change, versions follow each change: `2.1.2`, `2.1.3`, `2.1.4`, `2.2.0`. +- Merged per change, versions follow each change: `1.2.1`, `1.2.2`, `1.2.3`, `1.3.0`. - Left to accumulate, a backlog collapses into one bump and the numbers in between never exist. The repository variable `AUTO_MERGE_RELEASE_PR` chooses between the two. Set to `true`, the release pull request merges itself once its required checks pass, giving one release per change. Unset or `false`, a maintainer merges it when a release is wanted. From c5a1ea2be947d575236aad11188aaaa04dd8434b Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 19:42:29 -0400 Subject: [PATCH 2/2] ci: treat the native release assets as one set, not a bag of files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest.json pins a sha256 for every zip and the installers verify against it, so uploading only the missing members of a partial set would pair zips from one build with checksums from another — a rebuild is not guaranteed to be byte-identical even at the same commit. Upload all of them or none, and stop the job on a partial set so a person decides between keeping what is published and cutting the next patch. Also label the example fence in CONTRIBUTING.md. --- .github/workflows/release.yml | 36 +++++++++++++++++++++-------------- CONTRIBUTING.md | 4 ++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bdac474..1035226a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -650,10 +650,15 @@ jobs: # source change would put different bytes behind a version number users # already have. A version has to identify one exact build forever. # - # Uploading only what is missing keeps both properties: a run that failed - # part way through still completes on a re-run, and a rebuild of an - # already-published version is left on the floor instead of shipped under - # its old number. To correct a bad build, cut the next patch. + # These assets are one set, not a bag of files: manifest.json pins a + # sha256 for every zip, and install.sh / install.ps1 verify against it. + # So the rule is all or nothing. A release that already carries the whole + # set is finished and is left alone; an empty one gets everything. A + # partial set stops the job, because filling in the gaps would pair zips + # from one build with checksums from another, and a rebuild is not + # guaranteed to be byte-identical even at the same commit. Sorting that + # out is a decision for a person: either what is published is the + # release, or it needs a new patch version. - name: Upload assets to GitHub Release shell: bash env: @@ -662,21 +667,24 @@ jobs: run: | set -euo pipefail existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')" - missing=() - skipped=() + present=() + absent=() for path in dist-native-release/*; do name="$(basename "$path")" if printf '%s\n' "$existing" | grep -qxF "$name"; then - skipped+=("$name") + present+=("$name") else - missing+=("$path") + absent+=("$path") fi done - if [ "${#skipped[@]}" -gt 0 ]; then - echo "::notice::Already on ${RELEASE_TAG} and left untouched: ${skipped[*]}" - fi - if [ "${#missing[@]}" -eq 0 ]; then - echo "::notice::Every asset is already on ${RELEASE_TAG}; nothing to upload." + if [ "${#absent[@]}" -eq 0 ]; then + echo "::notice::All ${#present[@]} assets are already on ${RELEASE_TAG}; nothing to upload." exit 0 fi - gh release upload "$RELEASE_TAG" "${missing[@]}" + if [ "${#present[@]}" -gt 0 ]; then + echo "::error::${RELEASE_TAG} already carries ${#present[@]} of these assets: ${present[*]}" + echo "::error::Adding the rest would mix two builds behind one version - manifest.json pins a sha256 per zip." + echo "::error::Resolve by hand: keep what is published, or cut the next patch and release that instead." + exit 1 + fi + gh release upload "$RELEASE_TAG" "${absent[@]}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dee8af7f..dfdbd3e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ Pythinker Code follows semantic versioning. The commit type you would have writt Each number counts on its own and none of them roll over at nine. Ten fixes on top of `1.2.0` land on `1.2.10`, not `1.3.0`; the minor moves only when a feature ships, and the major only when something breaks: -``` +```text 1.2.9 + fix → 1.2.10 1.9.9 + fix → 1.9.10 1.9.9 + feature → 1.10.0 @@ -116,7 +116,7 @@ Never do any of these: To correct a bad build, cut the next patch instead: fix the problem, bump, build from that exact commit, sign and notarize, and publish fresh artifacts under the new number. The updater then advertises the new version, and anyone who already installed the old one keeps a build that still matches what it claims to be. -CI enforces this rather than trusting the rule. The desktop release uploads into a draft and refuses to touch a release that is already published; the native CLI job uploads only assets that are missing, so a run that failed part way through still completes while a rebuild of a shipped version is left on the floor. +CI enforces this rather than trusting the rule. The desktop release uploads into a draft and refuses to touch a release that is already published. The native CLI job treats its assets as one set — `manifest.json` pins a sha256 for every zip — so it uploads all of them or none: a release that already has the full set is left alone, and a partial set stops the job rather than pairing zips from one build with checksums from another. ### Release cadence