Skip to content

Commit 6d27550

Browse files
authored
ci: automate authoritative desktop release tags (#154)
## Related Issue No issue — reported directly: the desktop app on Windows was still offering 0.1.6 after 0.2.0 had landed on main. ## Problem `apps/desktop` is a private workspace package. Changesets bumps its version on a release merge, but nothing ships it: `desktop-release.yml` fires only on a `desktop-v*` tag, and cutting that tag was a manual `git tag && git push` step outside CI. On the 1.0.0 release, `apps/desktop/package.json` went `0.1.6 -> 0.2.0` and no tag followed. The newest release in `PyModel/pythinker-desktop-releases` stayed `v0.1.6`, so its `latest.yml` — the electron-updater feed — kept advertising 0.1.6 to every installed client. Nothing was red; CI on main was fully green the whole time. A second problem sat next to it. Both platform jobs ran a "Stamp desktop version for tag builds" step that rewrote `apps/desktop/package.json` to whatever the tag said. `git tag desktop-v9.9.9 <any commit>` would therefore have silently turned that commit into a 9.9.9 release, with the tag — not the reviewed source — deciding what shipped. ## What changed **`release.yml` — cut the tag automatically.** After the changesets step, compare `apps/desktop/package.json` between `${GITHUB_SHA}^` and `${GITHUB_SHA}`; on a change, create `desktop-v<version>` at that commit. Both versions are read **by sha, never by `HEAD`**. This step runs after the changesets action, and on the run that opens the version PR that action checks out `changeset-release/main` and commits the bumped `package.json` files — so `HEAD` there is a bump that is not a release. Reading `HEAD` would cut, say, `desktop-v0.3.0` pointing at main's tip, where the package still says 0.2.0. The new `prepare` assertion would correctly fail *that* build, but the bogus tag would persist, and when the real 0.3.0 release landed the already-exists check would report "nothing to do" and desktop 0.3.0 would never ship — the same silent-no-ship failure this PR exists to close, through a new door. `$GITHUB_SHA` is the pushed commit whatever the action does to the checkout, which makes the question moot on both paths. For the same reason the already-exists check distinguishes a re-run from an anomaly: a tag on this commit is a `::notice::`, a tag on a *different* commit is an `::error::` naming both shas, rather than being waved through as benign. The tag is created with the release-bot App token, not `GITHUB_TOKEN`: GitHub does not start workflows from events generated with `GITHUB_TOKEN`, so a tag pushed with it would sit there without ever building. (The same token already pushes the `changeset-release/main` branch, which needs the same `contents: write` — but this specific path is exercised for the first time on the next desktop bump.) The step is `continue-on-error: true` and never exits non-zero on a read it cannot make. It runs inside the `release` job, which gates the npm publish, the Marketplace publish and the CDN redeploy; a missed desktop tag costs one manual `git tag`, while a failed step there would block all three. Every skip emits a `::notice::`, and a failed tag creation emits an `::error::` carrying the exact recovery command — a silent no-op is the bug being fixed, so no path here is allowed to be silent. It is deliberately not gated on `steps.changesets.outputs.published`. A desktop-only release publishes nothing to npm, so `published` is `false` on exactly the releases this needs to fire for. **`desktop-release.yml` — the package version is the source of truth.** Both "Stamp desktop version" steps are deleted. `prepare` now reads the version from `apps/desktop/package.json` and fails the build when a `desktop-v*` tag disagrees with it, instead of rewriting the tree to match. The tag chooses which commit ships; it can no longer change what that commit is. **`desktop-release.yml` — the tagged commit must be on main.** A new `prepare` step calls the compare API and accepts only `identical` or `behind`, so a release can no longer be built from a commit that never landed on main. Verification of the shell logic against real commits in this repo: | case | `prev` | `curr` | outcome | |---|---|---|---| | `35c89a73b` (the 1.0.0 release commit) | `0.1.6` | `0.2.0` | cuts `desktop-v0.2.0` — the tag this PR exists because nobody cut | | `3acb36c66` (an ordinary commit) | `0.1.6` | `0.1.6` | no tag | | unreadable ref | empty | empty | `::notice::`, exit 0 | | `desktop-v0.2.0` already at `35c89a73b…` | — | — | `::notice::` "already points here", exit 0 | | `desktop-v9.9.9` absent | — | — | `existing` empty, proceeds to create | Compare-API statuses were checked against this repository: `main...35c89a7` → `identical`, `main...3acb36c` → `behind`, `main...<branch head>` → `ahead`. `desktop-v0.2.0` was pushed by hand to unblock the current release before these changes land. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. — no test harness exists for workflow YAML; the shell logic was run against the real commits in the table above. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. — CI-only, nothing users perceive. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Release Improvements** * Desktop releases now consistently use the version defined by the desktop application. * Release tags are validated against the application version and confirmed to exist on the main branch. * Tag handling now reliably recognizes both annotated and direct release tags. * Unchanged desktop versions no longer trigger unnecessary releases. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5b6620e commit 6d27550

2 files changed

Lines changed: 86 additions & 24 deletions

File tree

.github/workflows/desktop-release.yml

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ jobs:
3333
with:
3434
persist-credentials: false
3535

36+
# apps/desktop/package.json is the single source of truth for the version.
37+
# The tag only selects which commit ships. Stamping the tag's version into
38+
# the tree instead would let `git tag desktop-v9.9.9 <any commit>` silently
39+
# rewrite that commit into a release nobody reviewed, so a disagreement is
40+
# a hard failure rather than a rewrite.
3641
- name: Resolve the desktop version
3742
id: resolve
3843
shell: bash
@@ -41,23 +46,43 @@ jobs:
4146
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/desktop-v') }}
4247
run: |
4348
set -euo pipefail
44-
# A tag build is authoritative; a manual run falls back to whatever
45-
# version the checked-out tree declares.
46-
if [ "$IS_TAG" = 'true' ]; then
47-
version="${TAG_NAME#desktop-v}"
48-
else
49-
version="$(node -p 'require("./apps/desktop/package.json").version')"
50-
fi
49+
version="$(node -p 'require("./apps/desktop/package.json").version')"
5150
if [ -z "$version" ]; then
5251
echo 'Could not resolve a desktop version' >&2
5352
exit 1
5453
fi
54+
if [ "$IS_TAG" = 'true' ]; then
55+
tag_version="${TAG_NAME#desktop-v}"
56+
if [ "$tag_version" != "$version" ]; then
57+
echo "::error::Tag ${TAG_NAME} does not match apps/desktop/package.json (${version})." >&2
58+
echo 'Bump the package version on main first, then tag that commit.' >&2
59+
exit 1
60+
fi
61+
fi
5562
# electron-builder names the GitHub release `v${version}`; the tag we
5663
# push here (`desktop-v*`) only triggers the workflow.
5764
echo "version=${version}" >> "$GITHUB_OUTPUT"
5865
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
5966
echo "Releasing desktop ${version} as tag v${version}."
6067
68+
# A release built from a commit that never landed on main ships code no
69+
# review gate ever saw. `compare` reports `identical` or `behind` when the
70+
# commit is an ancestor of main, and `diverged`/`ahead` when it is not.
71+
- name: Require the tagged commit to be on main
72+
if: startsWith(github.ref, 'refs/tags/desktop-v')
73+
shell: bash
74+
env:
75+
GH_TOKEN: ${{ github.token }}
76+
run: |
77+
set -euo pipefail
78+
status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" --jq '.status')"
79+
case "$status" in
80+
identical|behind) echo "${GITHUB_SHA} is on main (${status})." ;;
81+
*)
82+
echo "::error::${GITHUB_SHA} is not on main (compare status: ${status})." >&2
83+
exit 1 ;;
84+
esac
85+
6186
- name: Mint releases-repo token
6287
id: releases_token
6388
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
@@ -107,14 +132,6 @@ jobs:
107132

108133
- run: pnpm install --frozen-lockfile
109134

110-
- name: Stamp desktop version for tag builds
111-
if: startsWith(github.ref, 'refs/tags/desktop-v')
112-
env:
113-
TAG_NAME: ${{ github.ref_name }}
114-
run: |
115-
export DESKTOP_VERSION="${TAG_NAME#desktop-v}"
116-
node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);'
117-
118135
- name: Build workspace
119136
run: pnpm --workspace-root run build
120137

@@ -233,15 +250,6 @@ jobs:
233250
234251
- run: pnpm install --frozen-lockfile
235252
236-
- name: Stamp desktop version for tag builds
237-
if: startsWith(github.ref, 'refs/tags/desktop-v')
238-
shell: bash
239-
env:
240-
TAG_NAME: ${{ github.ref_name }}
241-
run: |
242-
export DESKTOP_VERSION="${TAG_NAME#desktop-v}"
243-
node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);'
244-
245253
- name: Build workspace
246254
run: pnpm --workspace-root run build
247255

.github/workflows/release.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,60 @@ jobs:
126126
# No NPM_TOKEN on purpose: changesets prefers it over OIDC when set, so
127127
# defining it would silently downgrade publishing to a long-lived token.
128128

129+
# apps/desktop is a private workspace package: changesets bumps its
130+
# version but nothing publishes it, and Desktop Release only fires on a
131+
# `desktop-v*` tag. A bump that nobody tags therefore leaves every
132+
# installed desktop client on the previous version with nothing red to
133+
# show for it — which is exactly how 0.2.0 sat unreleased behind 0.1.6.
134+
# Read both versions out of git rather than the working tree: the
135+
# changesets action rewrites package.json in place on the run that opens
136+
# the version PR, and that rewrite is not a release.
137+
# The App token matters: a tag pushed with GITHUB_TOKEN would not start
138+
# Desktop Release, because GitHub refuses to trigger workflows from it.
139+
# A desktop tag that fails to cut costs one manual `git tag`; a failed step
140+
# here would block npm, the Marketplace and the CDN behind it. Never let
141+
# this be the thing that stops a release — but annotate every skip, since
142+
# a silent no-op is the exact failure being fixed.
143+
- name: Cut the desktop release tag on a version bump
144+
continue-on-error: true
145+
env:
146+
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
147+
run: |
148+
set -uo pipefail
149+
# Read the pushed commit by sha, never HEAD. The changesets action
150+
# checks out changeset-release/main and commits the bumped
151+
# package.json files on the run that opens the version PR, so by the
152+
# time this step runs HEAD can be a bump that is not a release — and
153+
# tagging it would burn the next real version's tag name, which the
154+
# already-exists check below would then treat as done.
155+
read_version() { git show "$1:apps/desktop/package.json" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version' 2>/dev/null; }
156+
prev="$(read_version "${GITHUB_SHA}^" || true)"
157+
curr="$(read_version "${GITHUB_SHA}" || true)"
158+
if [ -z "$prev" ] || [ -z "$curr" ] || [ "$prev" = "$curr" ]; then
159+
echo "::notice::Desktop version unchanged or unreadable (prev='${prev}' curr='${curr}'); no tag cut."
160+
exit 0
161+
fi
162+
tag="desktop-v${curr}"
163+
# A tag on this commit is a re-run. A tag on a different commit is an
164+
# anomaly that would otherwise silently swallow this release.
165+
existing="$(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" |
166+
awk '$2 ~ /\^\{\}$/ { peeled=$1 } $2 !~ /\^\{\}$/ { direct=$1 } END { print (peeled ? peeled : direct) }')"
167+
if [ -n "$existing" ]; then
168+
if [ "$existing" = "$GITHUB_SHA" ]; then
169+
echo "::notice::${tag} already points here; nothing to do."
170+
else
171+
echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}. Desktop ${curr} will not ship until that is resolved."
172+
fi
173+
exit 0
174+
fi
175+
if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
176+
-f ref="refs/tags/${tag}" \
177+
-f sha="${GITHUB_SHA}" > /dev/null; then
178+
echo "::error::Could not cut ${tag}. Desktop ${curr} will not ship until someone pushes it: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}"
179+
exit 1
180+
fi
181+
echo "Cut ${tag} at ${GITHUB_SHA} (desktop ${prev} -> ${curr})."
182+
129183
- name: Request CodeRabbit review on version PR
130184
if: steps.changesets.outputs.published != 'true'
131185
env:

0 commit comments

Comments
 (0)