ci: main publishes, so a tag stops being required — and :latest stops shipping a dev client
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s

Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing
sufficient for a user to receive the build" — and here it was not. The
desktop and Android lanes BUILT on main and published nothing: `Publish
release` was gated on `refs/tags/v*`, the channel publishes on
`refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel
moved only when somebody cut a tag, which made a `v*` tag load-bearing
rather than the optional bookmark the model wants.

Both channels are rolling fixed-tag releases now. `dev` from dev, `stable`
from main, same machinery — `publish-release.sh` already took RELEASE_TAG,
`write-manifest.sh` already pruned, and both already PATCHed a stale
description on 409 (#2182). This is wiring, not new mechanism.

## The defect this carried

`ci.yml`'s "Fetch the Android client to bake in" read
`releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked
in the dev APK — `:latest` included — so a stable server served a
dev-channel client to anyone who downloaded it from there. That has nothing
to do with versioning; it is fixed here because this is the step that
finally gives `stable` an APK to point at.

It also means Android needs no channel machinery of its own. The APK is
served FROM the image, so the channel is already a property of which image
you run — note 3127 §7's "nothing to hand off" shape, arrived at here by
accident. One branch-conditional line, not a second channel in
`client_dist.py` as this milestone first assumed.

## The break this nearly shipped

`install.sh --channel stable` read the version out of `stable/latest.json`
and then fetched `releases/tags/v<version>` for the bundles — correct while
stable was a manifest-only pointer, and broken the moment stable holds its
own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for
everyone between this commit and the first merge to main.

Both channels are one lookup now: fetch the fixed-tag release, install what
is on it. A transitional fallback covers the window where `stable` still
has no bundles, marked for deletion in step 7 — without it the default
channel is broken for however long it takes to merge, and that window is
gated on an operator request rather than on this lane.

## The two writers problem

`stable`'s manifest was written by tag builds. It is written by main now,
and the tag path stops writing it — two writers for one channel is a race
with no winner worth having. A `v*` tag still writes its own versioned
manifest; its build consequence goes entirely in step 7.

Also corrected: `update.rs`'s header still described stable as following
`v*` tags. Nothing in that file moved — it only ever read
`<channel>/latest.json` — but the comment was a lie, and it is the file
somebody reads to understand the feed.

#3143

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 18:22:31 -04:00
co-authored by Claude Opus 5
parent b7e0e5dbba
commit c268ae4f23
7 changed files with 150 additions and 70 deletions
+20 -10
View File
@@ -199,19 +199,29 @@ jobs:
JSON
cat dist/thoughtsync-android.json
# The rolling dev channel, same fixed-tag release the desktop bundles use.
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
# target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot
# install over what they already have.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
# The rolling channel for this branch, the same fixed-tag releases the desktop
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot install
# over what they already have.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: bash desktop/packaging/publish-release.sh
run: |
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws
+12 -1
View File
@@ -330,7 +330,18 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
mkdir -p client
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. This read `download/dev`
# unconditionally until M314 step 3, on every branch — so every stable
# server shipped a dev-channel APK to anyone who downloaded the client
# from it. Not a versioning gap; a plain defect, fixed here because this
# is the step that gave `stable` an APK to point at.
case "${{ github.ref_name }}" in
main|v*) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
+73 -33
View File
@@ -216,26 +216,40 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
@@ -326,26 +340,40 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# The updater manifest, written AFTER both bundle jobs — they run in separate
@@ -359,7 +387,7 @@ jobs:
manifest:
name: Update manifest
needs: [build, windows]
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
@@ -380,19 +408,31 @@ jobs:
# could drift, and a manifest whose version doesn't match the binary it
# points at is an updater that never settles.
version="$(sh desktop/packaging/build-version.sh)"
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
# Rolling channel: drop the previous build's bundles once the manifest
# points at this one. Nothing can reach them, and they're ~100 MB a push.
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
else
export RELEASE_TAG="${GITHUB_REF_NAME}"
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
# Twice: once onto the versioned release itself, and once onto the
# permanent `stable` pointer the app actually reads. Same manifest both
# times — its URLs point at the versioned assets either way.
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
fi
# POSIX `case` (rule 81). For a tag, GITHUB_REF_NAME is the tag name, so
# the default arm is the tag path.
case "${GITHUB_REF_NAME}" in
dev|main)
# Both are rolling channels: the manifest lands on the same release
# that holds the bundles, and the previous build's bundles are dropped
# once it points at this one. Nothing can reach them, and they are
# ~100 MB a push.
case "${GITHUB_REF_NAME}" in
main) export RELEASE_TAG=stable
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
;;
*)
# A versioned release gets its own manifest and NOTHING ELSE. It used
# to also write the `stable` pointer — that moved to main above, and
# two writers for one channel is a race with no winner worth having.
# The tag's build consequence goes entirely in M314 step 7; this only
# stops it fighting over `stable`.
export RELEASE_TAG="${GITHUB_REF_NAME}"
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
;;
esac