Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Step 7 of M314, the last one. Rule 22 — the old path comes out completely. ## A release stops building `desktop.yml` no longer triggers on `v*`, and its two `Publish release` steps are gone. `ci.yml` lost its tag trigger in step 6. So a tag now reaches exactly one lane: the new `release.yml`, which builds nothing. That is not a simplification for its own sake. The merge to `main` already published everything a user can receive — `:latest` + `:<sha>`, both channel feeds, the updater manifest. A tag rebuilding that source produces identical artifacts under identical names and re-pushes `:<sha>` with different bytes, which rule 145 forbids even when they match. ## So what a release is FOR The changelog (note 3127 §5). Two halves to "what am I running", and the version answers only the first: which build is this (the footer, /api/config, the APK's versionName) and what is in it that was not in the one I ran last month (nothing, until now). `packaging/release-notes.sh` derives it from git rather than a hand-maintained CHANGELOG, which drifts into recording what someone MEANT to ship. Capped at 60 entries with the omitted count stated — the first dated release spans 181 commits since `v0.1.0`, and a truncated list that does not say it is truncated is a lie. It publishes through `publish-release.sh` rather than making its own API calls, for the create-or-PATCH-on-409 path: a fixed-tag release that only ever POSTs keeps whatever body its first run wrote, which is #2182, and reimplementing that correctly in a second place is how it comes back. ## Retired `MANIFEST_TAG` and the whole branch behind it. It let the manifest live on a `stable` pointer release while the bundles sat on a versioned one — a split step 3 removed when `stable` started holding its own bundles. Nothing had passed it since; a parameter that can only ever receive its own default is a branch nobody exercises and a comment that goes stale, and its stale text was still telling readers the installable builds live on the versioned releases. `desktop/src-tauri/Cargo.toml`'s version and `thoughtsync/__init__.py`'s both now say out loud that they are not shipped values. The Cargo one carries the history worth keeping: the old scheme took its base from that line, so `0.2.<run>` on dev outranked a bare `0.2.0` on main, and the remedy was "remember to bump the minor before tagging" — documented in a comment, enforced nowhere. #2183 is what that looked like in the field. **That ritual is now formally dead**, and this is the deliberate act of killing it rather than a side effect. ## Still there on purpose `install.sh`'s transitional stable fallback. It cannot go until `main` has published to `stable` at least once, and that is gated on an operator request. Removing it now would break the DEFAULT install channel. #3147 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
11 KiB
Bash
Executable File
200 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Publish the built desktop bundles as assets on a Fabled-Git (Forgejo) Release.
|
|
#
|
|
# WHY: CI Actions artifacts are ephemeral, per-run, and auth-gated — useless as a
|
|
# distribution/fetch target. The install script (install.sh) and the in-app
|
|
# updater both need a STABLE, versioned URL. A Release attached to the pushed
|
|
# `v*` tag is that target: `/releases/latest` always points at the newest one,
|
|
# and each asset has a permanent browser_download_url.
|
|
#
|
|
# WHEN: CI-only, and ONLY on a `v*` tag build (the workflow gates this step with
|
|
# `if: startsWith(github.ref, 'refs/tags/v')`). Cutting the tag is the operator's
|
|
# action (rule 2) — this script never creates a tag, it only publishes a Release
|
|
# for a tag that already exists.
|
|
#
|
|
# The build + de-bundle steps run first; this consumes their output:
|
|
# target/release/bundle/appimage/*.AppImage (de-bundled)
|
|
# target/release/bundle/deb/*.deb
|
|
# target/release/bundle/arch/*.pkg.tar.* (prebuilt pacman)
|
|
# target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
|
#
|
|
# Instance-agnostic: server + repo come from the runner's github.* context
|
|
# (Forgejo populates them for compatibility), so nothing is hardcoded to one host.
|
|
#
|
|
# Idempotent: re-running for the same tag reuses the existing Release and
|
|
# replaces same-named assets, so a re-run (or workflow_dispatch retry) is safe.
|
|
set -euo pipefail
|
|
|
|
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required (runner-injected; needs contents:write)}"
|
|
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
|
|
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
|
|
: "${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
|
|
# `/releases/latest/download/<asset>` route for an updater to point at.
|
|
RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
|
RELEASE_PRERELEASE="${RELEASE_PRERELEASE:-false}"
|
|
|
|
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
|
TAG="$RELEASE_TAG"
|
|
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
BUNDLE_ROOT="$REPO_ROOT/target/release/bundle"
|
|
# Cross-compiled Windows output lands under the target triple, not the host root.
|
|
WIN_BUNDLE_ROOT="$REPO_ROOT/target/x86_64-pc-windows-msvc/release/bundle"
|
|
|
|
# --- collect the assets to upload -------------------------------------------
|
|
shopt -s nullglob
|
|
# nullglob (set above) drops the patterns that didn't match, which is what lets the
|
|
# Linux job and the Windows job each run this script against the SAME release and
|
|
# upload only what they actually built — they run in separate workspaces, so neither
|
|
# can see the other's bundles. The release is created once and reused (409 path).
|
|
# The `.sig` files are the updater's whole trust story — a bundle published without
|
|
# its signature is one the app will refuse, so they ship together or not at all.
|
|
# They only exist when the build ran with a signing key (M10.9); nullglob drops
|
|
# them silently otherwise, which is the correct behaviour for an unsigned build.
|
|
# The Android client publishes here too, from its own job and its own workspace
|
|
# — the same nullglob arrangement that already lets the Linux and Windows jobs
|
|
# share one release. Its two files are staged under android/dist by the workflow:
|
|
# a STABLY NAMED apk (a fixed name is the whole point of the fixed `dev` tag —
|
|
# Forgejo has no /releases/latest/download route) and the sidecar the server reads
|
|
# its version out of, because an APK keeps that in a binary manifest.
|
|
ASSETS=(
|
|
"$BUNDLE_ROOT"/appimage/*.AppImage
|
|
"$BUNDLE_ROOT"/appimage/*.AppImage.sig
|
|
"$BUNDLE_ROOT"/deb/*.deb
|
|
"$BUNDLE_ROOT"/arch/*.pkg.tar.*
|
|
"$WIN_BUNDLE_ROOT"/nsis/*.exe
|
|
"$WIN_BUNDLE_ROOT"/nsis/*.exe.sig
|
|
"$REPO_ROOT"/android/dist/thoughtsync.apk
|
|
"$REPO_ROOT"/android/dist/thoughtsync-android.json
|
|
)
|
|
# nullglob drops PATTERNS that match nothing — it does nothing for a path with no
|
|
# wildcard in it, which stays in the array as a literal and reaches curl as a file
|
|
# that isn't there (exit 26). The Android entries above are exactly that shape, and
|
|
# adding them broke the desktop publish that had been working. Filter on existence
|
|
# instead, which is what the array actually means and covers every entry rather
|
|
# than only the ones that happen to contain a `*`.
|
|
present=()
|
|
for a in "${ASSETS[@]}"; do
|
|
[ -f "$a" ] && present+=("$a")
|
|
done
|
|
ASSETS=("${present[@]}")
|
|
|
|
if [ ${#ASSETS[@]} -eq 0 ]; then
|
|
echo "ERROR: nothing to publish — no desktop bundles under $BUNDLE_ROOT and no APK under $REPO_ROOT/android/dist." >&2
|
|
exit 1
|
|
fi
|
|
echo "==> Publishing release $TAG with ${#ASSETS[@]} asset(s):"
|
|
for a in "${ASSETS[@]}"; do echo " $(basename "$a") ($(du -h "$a" | cut -f1))"; done
|
|
|
|
# curl wrapper that returns the response body on stdout and fails the script on
|
|
# an HTTP >=400 that we didn't explicitly allow (via ALLOW_CODES).
|
|
api() {
|
|
local method="$1" url="$2"; shift 2
|
|
local out code
|
|
out="$(curl -sS -X "$method" "${AUTH[@]}" -w $'\n%{http_code}' "$url" "$@")"
|
|
code="${out##*$'\n'}"
|
|
out="${out%$'\n'*}"
|
|
if [ "$code" -ge 400 ] && [[ " ${ALLOW_CODES:-} " != *" $code "* ]]; then
|
|
echo "ERROR: $method $url -> HTTP $code" >&2
|
|
echo "$out" >&2
|
|
return 1
|
|
fi
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# First integer value of a "id": <n> pair — the release id is the first "id" in
|
|
# the release object. Avoids a jq dependency (not guaranteed in the CI image).
|
|
first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'; }
|
|
|
|
# --- create (or reuse) the release for the tag ------------------------------
|
|
# The install command printed on a release has to install THAT release's channel.
|
|
# install.sh defaults to stable, so the rolling dev release must opt in explicitly
|
|
# — otherwise someone following the instructions here lands on a tagged build and
|
|
# wonders why the version they were sent isn't what they got.
|
|
#
|
|
# Both CHANNELS are rolling pointer releases (M314 step 3): `dev` republishes on
|
|
# 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
|
|
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.
|
|
# Here single quotes already do that job, so a backslash would survive into the
|
|
# 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
|
|
# 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
|
|
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"}
|
|
JSON
|
|
)
|
|
|
|
# An explicit body, replacing the install instructions above.
|
|
#
|
|
# Used by the RELEASE lane, whose job is a changelog rather than artifacts (M314
|
|
# step 7). It goes through this script rather than making its own API calls so that
|
|
# the create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever
|
|
# POSTs keeps whatever text its FIRST build wrote, which is #2182 exactly, and
|
|
# re-implementing that correctly in a second place is how it comes back.
|
|
#
|
|
# CONTRACT: already JSON-escaped, without surrounding quotes. The caller knows
|
|
# 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\"}"
|
|
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")"
|
|
RELEASE_ID="$(printf '%s' "$release" | first_id || true)"
|
|
|
|
if [ -z "${RELEASE_ID:-}" ]; then
|
|
echo " release exists; fetching it by tag"
|
|
release="$(api GET "$API/releases/tags/$TAG")"
|
|
RELEASE_ID="$(printf '%s' "$release" | first_id)"
|
|
# And refresh its description. A fixed-tag release (dev, and any re-run) keeps
|
|
# whatever text the FIRST build wrote — including the install command. A stale
|
|
# one sends people to the wrong channel, silently.
|
|
echo " refreshing its description"
|
|
api PATCH "$API/releases/$RELEASE_ID" -H "Content-Type: application/json" -d "$BODY" >/dev/null
|
|
fi
|
|
[ -n "${RELEASE_ID:-}" ] || { echo "ERROR: could not resolve release id" >&2; exit 1; }
|
|
echo " release id = $RELEASE_ID"
|
|
|
|
# Existing assets (name -> id), so a re-run replaces rather than duplicates.
|
|
existing="$(api GET "$API/releases/$RELEASE_ID/assets")"
|
|
|
|
# --- upload each asset ------------------------------------------------------
|
|
for asset in "${ASSETS[@]}"; do
|
|
name="$(basename "$asset")"
|
|
# If an asset with this name already exists, delete it first (Forgejo rejects
|
|
# a duplicate name). Match the "id" that precedes this asset's "name".
|
|
old_id="$(printf '%s' "$existing" \
|
|
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$name\"" \
|
|
| first_id || true)"
|
|
if [ -n "${old_id:-}" ]; then
|
|
echo "==> Replacing existing asset $name (id $old_id)"
|
|
api DELETE "$API/releases/$RELEASE_ID/assets/$old_id" >/dev/null
|
|
fi
|
|
echo "==> Uploading $name"
|
|
api POST "$API/releases/$RELEASE_ID/assets?name=$name" -F "attachment=@$asset" >/dev/null
|
|
done
|
|
|
|
echo "==> Done. Release $TAG published with $(printf '%s\n' "${ASSETS[@]##*/}" | tr '\n' ' ')"
|