AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
12 changed files with 227 additions and 33 deletions
Showing only changes of commit 72968897ab - Show all commits
+5 -2
View File
@@ -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
+22 -6
View File
@@ -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
+16 -9
View File
@@ -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/<asset>` 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/<asset>` 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
+10 -1
View File
@@ -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
+13 -6
View File
@@ -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/<asset>` 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 <<JSON
{"tag_name":"$TAG","name":"ThoughtSync $TAG","draft":false,"prerelease":$RELEASE_PRERELEASE,
"body":"ThoughtSync $TAG.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
{"tag_name":"$TAG","name":"ThoughtSync $CHANNEL_LABEL","draft":false,"prerelease":$RELEASE_PRERELEASE,
"body":"ThoughtSync $CHANNEL_LABEL.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
JSON
)
@@ -158,7 +165,7 @@ JSON
# whether it has a JSON encoder; this script cannot assume python3 is on PATH in
# every image that sources it.
if [ -n "${RELEASE_BODY_JSON:-}" ]; then
BODY="{\"tag_name\":\"$TAG\",\"name\":\"ThoughtSync $TAG\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
BODY="{\"tag_name\":\"$TAG\",\"name\":\"ThoughtSync $CHANNEL_LABEL\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
fi
# 409 = a release for this tag already exists (re-run) — fall through to lookup.
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
+35
View File
@@ -161,6 +161,41 @@ printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
"$DISPLAY_VERSION" "$APP_VERSION" > "$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 ---------------------------
+28 -2
View File
@@ -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 <branch>`
// 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());
}
+3 -3
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env sh
#
# The release tag that holds a channel's builds.
#
# channel-tag.sh <dev|stable>
#
# 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
+4 -1
View File
@@ -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"
+9 -3
View File
@@ -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
;;
+53
View File
@@ -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 == ""