diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 1c7d2eb8b..23bfcdeb2 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -33,6 +33,11 @@ jobs: with: persist-credentials: false + # apps/desktop/package.json is the single source of truth for the version. + # The tag only selects which commit ships. Stamping the tag's version into + # the tree instead would let `git tag desktop-v9.9.9 ` silently + # rewrite that commit into a release nobody reviewed, so a disagreement is + # a hard failure rather than a rewrite. - name: Resolve the desktop version id: resolve shell: bash @@ -41,23 +46,43 @@ jobs: IS_TAG: ${{ startsWith(github.ref, 'refs/tags/desktop-v') }} run: | set -euo pipefail - # A tag build is authoritative; a manual run falls back to whatever - # version the checked-out tree declares. - if [ "$IS_TAG" = 'true' ]; then - version="${TAG_NAME#desktop-v}" - else - version="$(node -p 'require("./apps/desktop/package.json").version')" - fi + version="$(node -p 'require("./apps/desktop/package.json").version')" if [ -z "$version" ]; then echo 'Could not resolve a desktop version' >&2 exit 1 fi + if [ "$IS_TAG" = 'true' ]; then + tag_version="${TAG_NAME#desktop-v}" + if [ "$tag_version" != "$version" ]; then + echo "::error::Tag ${TAG_NAME} does not match apps/desktop/package.json (${version})." >&2 + echo 'Bump the package version on main first, then tag that commit.' >&2 + exit 1 + fi + fi # electron-builder names the GitHub release `v${version}`; the tag we # push here (`desktop-v*`) only triggers the workflow. echo "version=${version}" >> "$GITHUB_OUTPUT" echo "tag=v${version}" >> "$GITHUB_OUTPUT" echo "Releasing desktop ${version} as tag v${version}." + # A release built from a commit that never landed on main ships code no + # review gate ever saw. `compare` reports `identical` or `behind` when the + # commit is an ancestor of main, and `diverged`/`ahead` when it is not. + - name: Require the tagged commit to be on main + if: startsWith(github.ref, 'refs/tags/desktop-v') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" --jq '.status')" + case "$status" in + identical|behind) echo "${GITHUB_SHA} is on main (${status})." ;; + *) + echo "::error::${GITHUB_SHA} is not on main (compare status: ${status})." >&2 + exit 1 ;; + esac + - name: Mint releases-repo token id: releases_token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0 @@ -107,14 +132,6 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Stamp desktop version for tag builds - if: startsWith(github.ref, 'refs/tags/desktop-v') - env: - TAG_NAME: ${{ github.ref_name }} - run: | - export DESKTOP_VERSION="${TAG_NAME#desktop-v}" - 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`);' - - name: Build workspace run: pnpm --workspace-root run build @@ -233,15 +250,6 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Stamp desktop version for tag builds - if: startsWith(github.ref, 'refs/tags/desktop-v') - shell: bash - env: - TAG_NAME: ${{ github.ref_name }} - run: | - export DESKTOP_VERSION="${TAG_NAME#desktop-v}" - 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`);' - - name: Build workspace run: pnpm --workspace-root run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cd4a02c6..1cbccd57b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,6 +126,60 @@ jobs: # No NPM_TOKEN on purpose: changesets prefers it over OIDC when set, so # defining it would silently downgrade publishing to a long-lived token. + # apps/desktop is a private workspace package: changesets bumps its + # version but nothing publishes it, and Desktop Release only fires on a + # `desktop-v*` tag. A bump that nobody tags therefore leaves every + # installed desktop client on the previous version with nothing red to + # show for it — which is exactly how 0.2.0 sat unreleased behind 0.1.6. + # Read both versions out of git rather than the working tree: the + # changesets action rewrites package.json in place on the run that opens + # the version PR, and that rewrite is not a release. + # The App token matters: a tag pushed with GITHUB_TOKEN would not start + # Desktop Release, because GitHub refuses to trigger workflows from it. + # A desktop tag that fails to cut costs one manual `git tag`; a failed step + # here would block npm, the Marketplace and the CDN behind it. Never let + # this be the thing that stops a release — but annotate every skip, since + # a silent no-op is the exact failure being fixed. + - name: Cut the desktop release tag on a version bump + continue-on-error: true + env: + GH_TOKEN: ${{ steps.release-bot.outputs.token }} + run: | + set -uo pipefail + # Read the pushed commit by sha, never HEAD. The changesets action + # checks out changeset-release/main and commits the bumped + # package.json files on the run that opens the version PR, so by the + # time this step runs HEAD can be a bump that is not a release — and + # tagging it would burn the next real version's tag name, which the + # already-exists check below would then treat as done. + 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; } + prev="$(read_version "${GITHUB_SHA}^" || true)" + curr="$(read_version "${GITHUB_SHA}" || true)" + if [ -z "$prev" ] || [ -z "$curr" ] || [ "$prev" = "$curr" ]; then + echo "::notice::Desktop version unchanged or unreadable (prev='${prev}' curr='${curr}'); no tag cut." + exit 0 + fi + tag="desktop-v${curr}" + # A tag on this commit is a re-run. A tag on a different commit is an + # anomaly that would otherwise silently swallow this release. + existing="$(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" | + awk '$2 ~ /\^\{\}$/ { peeled=$1 } $2 !~ /\^\{\}$/ { direct=$1 } END { print (peeled ? peeled : direct) }')" + if [ -n "$existing" ]; then + if [ "$existing" = "$GITHUB_SHA" ]; then + echo "::notice::${tag} already points here; nothing to do." + else + echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}. Desktop ${curr} will not ship until that is resolved." + fi + exit 0 + fi + if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${tag}" \ + -f sha="${GITHUB_SHA}" > /dev/null; then + echo "::error::Could not cut ${tag}. Desktop ${curr} will not ship until someone pushes it: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}" + exit 1 + fi + echo "Cut ${tag} at ${GITHUB_SHA} (desktop ${prev} -> ${curr})." + - name: Request CodeRabbit review on version PR if: steps.changesets.outputs.published != 'true' env: