From c2fdc05e5c60f605ede4ccea99cb7b799b6d4495 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 29 Aug 2026 00:43:21 -0400 Subject: [PATCH] release: a tag builds nothing and carries a changelog instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` + `:`, both channel feeds, the updater manifest. A tag rebuilding that source produces identical artifacts under identical names and re-pushes `:` 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.` 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 --- .forgejo/workflows/desktop.yml | 74 ++++++-------------------- .forgejo/workflows/release.yml | 68 ++++++++++++++++++++++++ desktop/packaging/publish-release.sh | 15 ++++++ desktop/packaging/write-manifest.sh | 43 ++++++--------- desktop/src-tauri/Cargo.toml | 12 +++++ packaging/release-notes.sh | 79 ++++++++++++++++++++++++++++ src/thoughtsync/__init__.py | 7 +++ 7 files changed, 211 insertions(+), 87 deletions(-) create mode 100644 .forgejo/workflows/release.yml create mode 100755 packaging/release-notes.sh diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index c79c6e0..39748ef 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -25,12 +25,11 @@ on: # one. That is a ~15s container for a decision, against a lane that cannot # silently fail to run. branches: [dev, main] - tags: ["v*"] workflow_dispatch: concurrency: group: desktop-${{ github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + cancel-in-progress: true permissions: # write (not read) so the tag build can publish a Release with the bundles @@ -52,7 +51,7 @@ jobs: # from "we derived a stale value that happens to match" unless something checks. decide: name: Build, or is the channel already serving this? - if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -69,15 +68,6 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - # A tag has no channel to compare against and still builds bundles until - # step 7 retires that lane. Always build, never skip. - case "${{ github.ref }}" in - refs/tags/*) - echo "Tag build — no channel to compare against." - echo "build=true" >> $GITHUB_OUTPUT - exit 0 - ;; - esac case "$GITHUB_REF_NAME" in main) channel=stable ;; *) channel=dev ;; @@ -252,17 +242,6 @@ jobs: # failure, not as a green run with an empty artifact. if-no-files-found: error - # Tag builds only: publish a real, versioned Fabled-Git Release with the - # AppImage + .deb attached — the stable fetch target the install script and - # the in-app updater consume (Actions artifacts above are ephemeral/test). - # Cutting the tag is the operator's action (rule 2); this only publishes a - # Release for a tag that already exists. Dormant on dev/main pushes. - - name: Publish release - if: startsWith(github.ref, 'refs/tags/v') - env: - GITHUB_TOKEN: ${{ github.token }} - run: bash desktop/packaging/publish-release.sh - # 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/ route, so "newest" @@ -391,15 +370,6 @@ jobs: path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe if-no-files-found: error - # Publishes to the SAME release as the Linux job. Safe to run twice: the - # script reuses an existing release (409) and nullglob means each job uploads - # only the bundles present in its own workspace. - - name: Publish release - if: startsWith(github.ref, 'refs/tags/v') - env: - GITHUB_TOKEN: ${{ github.token }} - run: bash desktop/packaging/publish-release.sh - # 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/ route, so "newest" @@ -447,7 +417,7 @@ jobs: manifest: name: Update manifest needs: [build, windows] - if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 @@ -477,31 +447,17 @@ jobs: # match the binary it points at is an updater that never settles. It must # be `key`: this value is matched against bundle filenames. version="$(sh packaging/version.sh key desktop)" - # POSIX `case` (rule 81). For a tag, GITHUB_REF_NAME is the tag name, so - # the default arm is the tag path. + # Both channels are rolling: 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. + # + # 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 - 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 - ;; + 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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..a647444 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7). +# +# The merge to `main` already published everything a user can receive: the server +# image as `:latest` + `:`, the desktop bundles and the APK to the `stable` +# channel, and the updater manifest that advertises them. A tag rebuilding that same +# source would produce identical artifacts under identical names, and would re-push +# `:` with different bytes — which rule 145 forbids even when they match. +# +# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying +# what was in it. Note 3127 §5 — there are 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 +# what changed since the one ← this +# I was running last month? +# +# Cutting the tag is the operator's act (rule 2). This only responds to one. +# +# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on +# tags at all. The image is addressed by channel or by commit; the release by date. +# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different +# system. + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + notes: + name: Write the changelog + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v6 + with: + # The whole history AND every tag: the notes are the commit range between + # this tag and the previous `v*` one, and neither end exists in a shallow + # clone. A depth-limited checkout here does not fail — it produces a + # shorter changelog, which is the kind of wrong nobody notices. + fetch-depth: 0 + + - name: Publish the release notes + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")" + echo "$notes" + echo "---" + + # JSON-escaped HERE rather than in publish-release.sh, which cannot assume + # python3 is on PATH in the three images that call it. `json.dumps` then + # strip the surrounding quotes — the script supplies those. + RELEASE_BODY_JSON="$(printf '%s' "$notes" \ + | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')" + export RELEASE_BODY_JSON + + # Through publish-release.sh for its create-or-PATCH-on-409 path: a + # release that is only ever POSTed keeps whatever body its first run + # wrote (#2182), so re-tagging or re-running must rewrite it. No bundles + # exist in this workspace, so its asset globs match nothing and it + # uploads none — which is the intended behaviour, not a side effect. + RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh diff --git a/desktop/packaging/publish-release.sh b/desktop/packaging/publish-release.sh index 496c443..0305d11 100755 --- a/desktop/packaging/publish-release.sh +++ b/desktop/packaging/publish-release.sh @@ -145,6 +145,21 @@ BODY=$(cat < Manifest:" cat "$work/latest.json" -# --- resolve the release the manifest is published TO ------------------------ -if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then - target_id="$release_id" - target_assets="$assets" -else - echo "==> Resolving the $MANIFEST_TAG channel release" - target="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$MANIFEST_TAG")" - target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)" - if [ -z "${target_id:-}" ]; then - # First publish to this channel. A pointer release: no bundles of its own, just - # a permanent tag for the manifest to live under. - echo " creating it (pointer release, manifest only)" - body="{\"tag_name\":\"$MANIFEST_TAG\",\"name\":\"ThoughtSync ($MANIFEST_TAG channel)\",\"draft\":false,\"prerelease\":false,\"body\":\"Update channel pointer. The installable builds live on the versioned releases; this holds only the updater manifest.\"}" - target="$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$API/releases")" - target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')" - fi - [ -n "${target_id:-}" ] || { echo "ERROR: could not resolve the $MANIFEST_TAG release" >&2; exit 1; } - target_assets="$(curl -sS "${AUTH[@]}" "$API/releases/$target_id/assets")" -fi +# The manifest goes on the same release the bundles were just read from — which is +# also the one `publish-release.sh` created or refreshed moments earlier, so it is +# guaranteed to exist by the time this runs. +target_id="$release_id" +target_assets="$assets" # Replace rather than duplicate: Forgejo rejects a second asset with the same name, # and this file is rewritten on every publish by design. @@ -145,11 +132,11 @@ if [ -n "${old_id:-}" ]; then curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null fi -echo "==> Uploading latest.json to $MANIFEST_TAG" +echo "==> Uploading latest.json to $RELEASE_TAG" curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \ -F "attachment=@$work/latest.json" >/dev/null -echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)." +echo "==> Done. $RELEASE_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)." # --- prune superseded builds from a rolling channel --------------------------- # diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a43e60e..f7720e1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,5 +1,17 @@ [package] name = "thoughtsync-desktop" +# NOT THE SHIPPED VERSION, and bumping it has no effect on anything a user sees. +# +# Cargo requires a version here, and Tauri reads one from `tauri.conf.json` — both +# are overridden per build by `cargo tauri build --config '{"version": ...}'` with +# the value `packaging/version.sh key desktop` derives. See #3144. +# +# It used to matter: the old scheme took its base from this line and appended the CI +# run number on dev, so `0.2.` on dev sat against a bare `0.2.0` on main and +# every dev build outranked every stable one. The remedy was "remember to bump the +# minor before tagging" — documented in a comment, enforced nowhere, and #2183 is +# what that looked like in the field. A scheme needing a human to remember something +# before each release has not removed the decision, only hidden it. version = "0.2.0" description = "ThoughtSync desktop — local-first Keep-style thought capture" authors = ["bvandeusen"] diff --git a/packaging/release-notes.sh b/packaging/release-notes.sh new file mode 100755 index 0000000..48fecbf --- /dev/null +++ b/packaging/release-notes.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env sh +# +# The markdown body for a release: what went live since the previous one. +# +# release-notes.sh +# +# A release BUILDS NOTHING now (M314 step 7). The merge to `main` already published +# `:latest`, `:` and both channel feeds, so a tag rebuilding that same source +# would produce identical artifacts and re-push `:` with different bytes — +# which rule 145 forbids even when the bytes match. +# +# So what is a release FOR? Note 3127 §5 answers it: the changelog. There are 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 +# what is in it that was not ← this +# in the one I ran last month +# +# DERIVED FROM GIT, not hand-maintained. A CHANGELOG.md drifts into being +# aspirational — it records what someone meant to ship. `git log` records what +# shipped, and cannot say otherwise. +set -eu + +cd "$(git rev-parse --show-toplevel)" + +tag="${1:?usage: release-notes.sh }" + +# The previous release tag, by DATE rather than by name. +# +# `v*` only: this repo also carries `dev` and `stable` tags, which are the fixed-tag +# pointer releases the updater reads. They move constantly and are not releases in +# this sense; sorting them in would make "the previous release" mean whichever +# channel published most recently. +# +# Excludes the tag being described, so re-running on an existing tag still produces +# the range that tag covers rather than an empty one. +prev="$(git tag -l 'v*' --sort=-creatordate | grep -vxF "$tag" | head -1 || true)" + +if [ -n "$prev" ]; then + range="$prev..$tag" + header="Changes since \`$prev\`." +else + # The first release. Everything is new, and listing the entire history would be + # noise — say so instead. + range="$tag" + header="First release." +fi + +printf 'ThoughtSync %s\n\n%s\n\n' "$tag" "$header" + +# `--no-merges`: a merge commit's subject is "Merge branch ..." and says nothing +# about what shipped. The commits it brought in are listed individually, which is +# what somebody reading this wants. +# +# `%s` alone, not `%s (%h)`: the sha is in the forge's own view of the release and +# a reader chasing a specific change clicks through rather than copying a hash out +# of prose. +# CAPPED, because an unbounded list is not a changelog — it is a wall. +# +# The first dated release spans everything since `v0.1.0` — 181 commits at the +# time of writing: nobody reads that, and burying twelve interesting changes in it is worse +# than not writing one. Later releases will be short and the cap will never bite. +# +# The most RECENT are kept, not the oldest, and the count of what was dropped is +# stated — a truncated list that does not say it is truncated is a lie. +CAP=60 +total="$(git log --no-merges --format='%s' "$range" | wc -l | tr -d ' ')" + +git log --no-merges --reverse --format='- %s' "$range" | tail -"$CAP" + +if [ "$total" -gt "$CAP" ]; then + printf '\n_...and %s earlier commits in this range, omitted for length._\n' \ + "$((total - CAP))" +fi + +printf '\n' +printf '%s\n' "_No artifacts here. Builds reach users from \`main\`: the desktop and Android" +printf '%s\n' "channels and the server image all publish on merge, with no tag required. This" +printf '%s\n' "release is a bookmark — it names a moment and says what was in it._" diff --git a/src/thoughtsync/__init__.py b/src/thoughtsync/__init__.py index 8d24818..0c2c273 100644 --- a/src/thoughtsync/__init__.py +++ b/src/thoughtsync/__init__.py @@ -1,3 +1,10 @@ """ThoughtSync — self-hosted personal thought-capture web app (FabledSword family).""" +# The FALLBACK version, used only when APP_VERSION is absent from the environment — +# i.e. running from a checkout rather than from an image. A built image always has +# it, derived from the server's own shipped file set (packaging/version.sh), so this +# string never reaches a deployed instance and bumping it changes nothing a user +# sees. Kept because a package needs a version and "unknown" is not a valid one for +# packaging metadata; the honest "I cannot say" for a running server is APP_VERSION +# being missing, which app.py already handles. __version__ = "0.2.0"