diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index 7f854b8..3665d9e 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -256,9 +256,12 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | case "$GITHUB_REF_NAME" in - main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; - *) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; + main) channel=stable; RELEASE_PRERELEASE=false ;; + *) channel=dev; RELEASE_PRERELEASE=true ;; esac + # The channel's release TAG, not its name: `dev` publishes on `dev-rolling` + # (packaging/channel-tag.sh). A tag named `dev` shadowed the branch. + RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")" export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing the APK to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 55cdc8c..7492fc6 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -278,9 +278,12 @@ jobs: # `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 ;; + main) channel=stable; RELEASE_PRERELEASE=false ;; + *) channel=dev; RELEASE_PRERELEASE=true ;; esac + # The channel's release TAG, not its name: `dev` publishes on `dev-rolling` + # (packaging/channel-tag.sh). A tag named `dev` shadowed the branch. + RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")" export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh @@ -412,9 +415,12 @@ jobs: # `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 ;; + main) channel=stable; RELEASE_PRERELEASE=false ;; + *) channel=dev; RELEASE_PRERELEASE=true ;; esac + # The channel's release TAG, not its name: `dev` publishes on `dev-rolling` + # (packaging/channel-tag.sh). A tag named `dev` shadowed the branch. + RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")" export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh @@ -473,11 +479,21 @@ jobs: # No tag arm any more. A `v*` tag does not reach this workflow at all — it # triggers release.yml, which writes a changelog and builds nothing. case "${GITHUB_REF_NAME}" in - main) export RELEASE_TAG=stable + main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)" export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;; - *) export RELEASE_TAG=dev + *) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)" + # TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed + # before the rename have .../download/dev/latest.json compiled in, + # so the manifest is also written to the old `dev` release; its + # URLs name dev-rolling assets, so those apps update once into a + # build that reads the new tag. Delete this line together with the + # old `dev` release and tag. + export BRIDGE_TAG=dev export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;; esac + # Assigned bare above so a failing channel-tag.sh fails this step; + # `export X="$(...)"` would swallow its exit status. + export RELEASE_TAG export PRUNE_OLD_ASSETS=true APP_VERSION="$version" DISPLAY_VERSION="$display" \ bash desktop/packaging/write-manifest.sh diff --git a/ci-requirements.md b/ci-requirements.md index 583ff73..99daebb 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -409,18 +409,25 @@ the lockfile format and the picked versions identical to what CI would have chosen. Commit the result in the same change as the `Cargo.toml` edit — a manifest change pushed without it fails the gate. -## Pushing: `dev` is both a branch and a tag +## Channel releases: `dev-rolling` and `stable` -`git push origin dev` fails in this repo: +The two update channels are releases on fixed tags, because Fabled-Git has no +`/releases/latest/download/` route and the updater needs a permanent URL. +The **channel** is still called `dev` everywhere a person sees it; its **release +tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads. +`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their +own copy because neither can run it — change all three together. -``` -error: src refspec dev matches more than one -``` +The tag used to be `dev`, which shadowed the branch of the same name: once a clone +had fetched it, `git push origin dev` failed with +`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel +tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if +one is. -The rolling update channel is a release on a **fixed tag named `dev`** (the tag -never moves — Fabled-Git has no `/releases/latest/download/` route, so the -updater needs a permanent URL). Once that tag is fetched locally, the short name -`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it: +**Transitional, from 2026-09-10:** the old `dev` release still exists so desktop +apps installed from it can update across — the manifest job writes `latest.json` +to it too (`BRIDGE_TAG=dev` in `desktop.yml`). Until that release and its tag are +deleted, fully qualify pushes: ``` git push origin refs/heads/dev:refs/heads/dev diff --git a/desktop/packaging/install.sh b/desktop/packaging/install.sh index 5ddded9..277afaa 100755 --- a/desktop/packaging/install.sh +++ b/desktop/packaging/install.sh @@ -93,7 +93,16 @@ say "Finding the latest ThoughtSync build on the $channel channel…" # are pruned to the current build, so the tag alone names the newest build on that # channel — which is exactly what an installer wants and what the in-app updater # already reads. -json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" || +# The channel's RELEASE TAG, which is not always its name: `dev` publishes on the +# tag `dev-rolling`, because a tag called `dev` shadowed the branch of the same name +# and broke `git push origin dev` (Scribe #2184). Same mapping as +# packaging/channel-tag.sh — inlined because this script is fetched alone through a +# pipe and has nothing to source. +case "$channel" in + dev) tag=dev-rolling ;; + *) tag="$channel" ;; +esac +json="$(curl -fsSL "$API/releases/tags/$tag" 2>/dev/null)" || die "the $channel channel has nothing published yet." # Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing diff --git a/desktop/packaging/publish-release.sh b/desktop/packaging/publish-release.sh index 0305d11..af245de 100755 --- a/desktop/packaging/publish-release.sh +++ b/desktop/packaging/publish-release.sh @@ -32,8 +32,9 @@ set -euo pipefail : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required (the tag, e.g. v0.1.0)}" # The release to publish to. Defaults to the pushed tag (the versioned, stable -# case). M10.9 also calls this with RELEASE_TAG=dev to maintain the rolling -# development channel — a release whose tag never moves, because Forgejo has no +# case). The channel publish steps call this with the channel's fixed tag — +# `stable`, or `dev-rolling` for the dev channel (packaging/channel-tag.sh) — to +# maintain a rolling release whose tag never moves, because Forgejo has no # `/releases/latest/download/` route for an updater to point at. RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}" RELEASE_PRERELEASE="${RELEASE_PRERELEASE:-false}" @@ -123,7 +124,11 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE # every green push to dev, `stable` on every merge to main. Each says so, because a # release that prunes its own assets behaves differently from a versioned one and a # reader deserves to know which they are looking at. -if [ "$TAG" = "dev" ]; then +# Keyed on the TAG, which for the dev channel is `dev-rolling`, not `dev` — a tag +# named `dev` shadowed the branch (Scribe #2184). CHANNEL_LABEL is the name a person +# reads in the release title, so the rename does not leak into it. +if [ "$TAG" = "dev-rolling" ]; then + CHANNEL_LABEL='dev (rolling)' INSTALL_TAIL='sh -s -- --channel dev' # Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash # is the shell's — it suppresses command substitution and never reaches the JSON. @@ -131,18 +136,20 @@ if [ "$TAG" = "dev" ]; then # body as `\``, which is not a legal JSON escape: Forgejo answers 422. CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.' elif [ "$TAG" = "stable" ]; then + CHANNEL_LABEL='stable' # install.sh defaults to stable, so no flag. INSTALL_TAIL='sh' CHANNEL_NOTE='\n\nThis is the rolling **stable** channel: republished on every merge to `main`, and pruned to the current build. No tag is required for a build to arrive here.' else + CHANNEL_LABEL="$TAG" INSTALL_TAIL='sh' CHANNEL_NOTE='' fi echo "==> Creating release for $TAG" BODY=$(cat < "$work/thoughtsync-desktop.json" replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json" +# --- TEMPORARY: the one-shot bridge off a retired channel tag ------------------ +# +# The dev channel's tag was renamed `dev` -> `dev-rolling`, because a tag named `dev` +# shadowed the branch and broke `git push origin dev` (Scribe #2184). Desktop apps +# installed before the rename have `.../download/dev/latest.json` compiled in, so +# without this they would never be offered another build. +# +# BRIDGE_TAG names the old release; the SAME manifest is written there too. Its URLs +# are absolute and name `dev-rolling` assets, so an old app updates once into a build +# that reads the new tag, and never reads this one again. Only latest.json goes +# across: the sidecar is read by CI and the image build, which already follow the +# new tag. +# +# A missing bridge release is logged and skipped, not fatal — by then it has been +# deleted on purpose. A failed WRITE to one that exists fails the job: a bridge that +# silently stops is an installed base stranded without a word. Remove this block, +# the BRIDGE_TAG line in desktop.yml, and the old release and tag together. +if [ -n "${BRIDGE_TAG:-}" ]; then + bridge_release="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$BRIDGE_TAG" || true)" + bridge_id="$(printf '%s' "$bridge_release" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)" + if [ -z "$bridge_id" ]; then + echo "==> No release tagged $BRIDGE_TAG — nothing to bridge (retired?)." + else + # A subshell, so replace_asset's globals point at the bridge release for this + # one call only and the prune below still works on $RELEASE_TAG. + ( + release_id="$bridge_id" + RELEASE_TAG="$BRIDGE_TAG" + assets="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")" + replace_asset "$work/latest.json" "latest.json" + ) + echo "==> Bridged: $BRIDGE_TAG/latest.json now advertises the same build." + fi +fi + echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)." # --- prune superseded builds from a rolling channel --------------------------- diff --git a/desktop/src-tauri/src/update.rs b/desktop/src-tauri/src/update.rs index ae25bd2..74fd63d 100644 --- a/desktop/src-tauri/src/update.rs +++ b/desktop/src-tauri/src/update.rs @@ -84,8 +84,21 @@ impl Channel { } } + /// The release tag holding this channel's builds — not the channel's name. + /// + /// `dev` used to be both, and a tag named `dev` shadowed the branch of the same + /// name, so `git push origin dev` failed as ambiguous (Scribe #2184). The channel + /// is still `dev` everywhere a person sees it and in the stored pref; only the tag + /// moved. Mirrors `packaging/channel-tag.sh`, which is what CI publishes with. + fn release_tag(self) -> &'static str { + match self { + Channel::Stable => "stable", + Channel::Dev => "dev-rolling", + } + } + fn feed_url(self) -> String { - format!("{FEED_BASE}/{}/latest.json", self.as_str()) + format!("{FEED_BASE}/{}/latest.json", self.release_tag()) } } @@ -415,10 +428,23 @@ mod tests { assert_eq!(effective(&db), Channel::Dev); } + #[test] + fn no_channel_feed_is_named_like_a_branch() { + // A release tag spelled like a branch makes `git push origin ` + // ambiguous once the tag is fetched (Scribe #2184). `dev` and `main` are the + // branches this repo pushes. + for channel in [Channel::Stable, Channel::Dev] { + assert!( + !["dev", "main"].contains(&channel.release_tag()), + "{channel:?} publishes on a tag named like a branch" + ); + } + } + #[test] fn each_channel_has_its_own_fixed_feed() { assert!(Channel::Stable.feed_url().ends_with("/stable/latest.json")); - assert!(Channel::Dev.feed_url().ends_with("/dev/latest.json")); + assert!(Channel::Dev.feed_url().ends_with("/dev-rolling/latest.json")); assert_ne!(Channel::Stable.feed_url(), Channel::Dev.feed_url()); } diff --git a/docs/android-distribution.md b/docs/android-distribution.md index 66c0505..4604513 100644 --- a/docs/android-distribution.md +++ b/docs/android-distribution.md @@ -55,8 +55,8 @@ real values are already known. so a build dropped there survives container recreation — and survives an image upgrade, which is the point of an override. -Both files are published to the rolling `dev` release on every green Android -build: +Both files are published to the rolling dev-channel release (tag `dev-rolling`) +on every green Android build: ```sh REPO=https://git.fabledsword.com/bvandeusen/thoughtsync @@ -64,7 +64,7 @@ TOKEN=... # a Fabled-Git token with read access; the instance is private for f in thoughtsync.apk thoughtsync-android.json; do curl -fsSL -H "Authorization: token $TOKEN" \ - -o "/tmp/$f" "$REPO/releases/download/dev/$f" + -o "/tmp/$f" "$REPO/releases/download/dev-rolling/$f" done # Copy the sidecar LAST. A sidecar that does not match the APK beside it is not a diff --git a/packaging/channel-tag.sh b/packaging/channel-tag.sh new file mode 100644 index 0000000..558474a --- /dev/null +++ b/packaging/channel-tag.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +# +# The release tag that holds a channel's builds. +# +# channel-tag.sh +# +# A channel and its release tag are two different names, and for `dev` they differ. +# The channel is what a person picks and sees (`install.sh --channel dev`, the app's +# settings); the tag is where CI publishes and where every reader fetches from. +# +# `dev` used to be both. A tag named `dev` shadowed the branch named `dev`, so once +# a clone had fetched tags, `git push origin dev` failed with "src refspec dev +# matches more than one" (Scribe #2184). The tag is `dev-rolling` now, matching +# roundtable-android. Never spell a channel tag like a branch — tests/test_channel_tag.py +# fails if one is. +# +# The ONE mapping CI reads. Two copies exist because their readers cannot run this: +# `desktop/src-tauri/src/update.rs` (compiled into the app) and +# `desktop/packaging/install.sh` (fetched alone through a pipe). Change all three. +set -eu + +case "${1:-}" in + dev) echo dev-rolling ;; + stable) echo stable ;; + *) + echo "channel-tag.sh: unknown channel '${1:-}' (expected dev or stable)" >&2 + exit 2 + ;; +esac diff --git a/packaging/fetch-clients.sh b/packaging/fetch-clients.sh index 4b96266..030a6c4 100755 --- a/packaging/fetch-clients.sh +++ b/packaging/fetch-clients.sh @@ -38,7 +38,10 @@ esac SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}" REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}" -BASE="$SERVER/$REPO/releases/download/$channel" +# The channel's release tag, not its name — `dev` lives on `dev-rolling` +# (packaging/channel-tag.sh, Scribe #2184). +tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")" +BASE="$SERVER/$REPO/releases/download/$tag" mkdir -p "$dest" diff --git a/packaging/guard-forward.sh b/packaging/guard-forward.sh index 7d60069..e20f3e2 100755 --- a/packaging/guard-forward.sh +++ b/packaging/guard-forward.sh @@ -96,16 +96,21 @@ fetch() { # merge to `main` (run 4857): exit 1, no output, 0.16 seconds, on the one channel that # had no APK published yet. Its three neighbours hid it until then. published_for() { + # The channel's release TAG, not its name — `dev` lives on `dev-rolling` + # (packaging/channel-tag.sh, Scribe #2184). `|| exit 2` rather than letting an + # empty tag through: a lookup against `.../download//latest.json` would find + # nothing and read as "nothing published", which this guard PASSES. + _tag="$(sh "$ROOT/packaging/channel-tag.sh" "$2")" || exit 2 case "$1" in desktop) # What the UPDATER reads. The manifest is the thing that decides whether a # client is offered a build, so it is the authority on what is published. - { fetch "$SERVER/$REPO/releases/download/$2/latest.json" \ + { fetch "$SERVER/$REPO/releases/download/$_tag/latest.json" \ | grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \ | sed -E 's/.*"([^"]+)"$/\1/'; } || true ;; android) - { fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \ + { fetch "$SERVER/$REPO/releases/download/$_tag/thoughtsync-android.json" \ | grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \ | grep -oE '[0-9]+$'; } || true ;; @@ -117,10 +122,11 @@ published_for() { # compares identity — for Android those are different fields, and conflating them # would make every build look like a change (the code is build-time; it always moves). published_name() { + _tag="$(sh "$ROOT/packaging/channel-tag.sh" "$2")" || exit 2 case "$1" in desktop) published_for desktop "$2" ;; android) - { fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \ + { fetch "$SERVER/$REPO/releases/download/$_tag/thoughtsync-android.json" \ | grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \ | sed -E 's/.*"([^"]+)"$/\1/'; } || true ;; diff --git a/tests/test_channel_tag.py b/tests/test_channel_tag.py new file mode 100644 index 0000000..3c70a8f --- /dev/null +++ b/tests/test_channel_tag.py @@ -0,0 +1,53 @@ +"""The channel -> release tag mapping — `packaging/channel-tag.sh`. + +A channel's release tag must never be spelled like a branch. The dev channel's tag +was `dev`, which shadowed the `dev` branch: once a clone had fetched tags, +`git push origin dev` failed as an ambiguous refspec (Scribe #2184). + +These run the script itself rather than reading it, so a mapping that is correct +on the page but broken in execution still fails here. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "channel-tag.sh" + +# The branches this repo pushes. A tag with either name makes the push ambiguous. +BRANCHES = {"dev", "main"} + + +def tag(channel: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["sh", str(SCRIPT), channel], + capture_output=True, text=True, + ) + + +@pytest.mark.parametrize("channel", ["dev", "stable"]) +def test_no_channel_tag_is_spelled_like_a_branch(channel: str) -> None: + result = tag(channel) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() not in BRANCHES + + +def test_the_dev_channel_publishes_on_dev_rolling() -> None: + # Pinned exactly, not just "not a branch": update.rs and install.sh carry + # their own copy of this value, and a silent rename here would strand them. + assert tag("dev").stdout.strip() == "dev-rolling" + + +def test_stable_keeps_its_tag() -> None: + assert tag("stable").stdout.strip() == "stable" + + +def test_an_unknown_channel_fails_instead_of_naming_a_tag() -> None: + # Every caller captures the output as a URL segment. An empty string on + # success would build `.../download//latest.json` and read as "nothing + # published" — so an unknown channel must exit non-zero and print nothing. + result = tag("nightly") + assert result.returncode != 0 + assert result.stdout == ""