Root, CI, docs and packaging describe the project to a stranger (#6225)
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 23s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m31s
Android / Build the server image (push) Successful in 1s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 23s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m31s
Android / Build the server image (push) Successful in 1s
#6225 "Comment standard sweep: root, config, CI workflows, docs and packaging", milestone 546 "Bring the repo to the comment standard (rule 32) and compose defaults (rule 198)". - README restructured in newcomer order: what it is, quick start, configuration, setup pitfalls, updating and backups, features, troubleshooting, development, licence. Its quick-start compose carries no restart policy. - Dockerfile and .env.example trimmed to one or two lines per setting. - Workflows, packaging scripts and docs lose issue, run, note, rule and milestone references and their history narration. Comments that still described path filters and "step 6" as future work now state what the workflows do. - ci-requirements.md rewritten against the current workflows. It said the build gated on typecheck and lint only, the integration lane did not gate, a v* tag published :latest, and ci-tauri lacked bsdtar and zstd; none of those is true now (the last checked against run 9630's log). - Echo strings in guard-forward.sh and version.sh drop their note citations; every string a test asserts on is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+16
-31
@@ -9,44 +9,30 @@
|
||||
|
||||
# --- required ---------------------------------------------------------------
|
||||
|
||||
# Generate one and keep it: changing it later means also changing it inside the
|
||||
# database, or Postgres will reject the app's connection.
|
||||
# Generate one and keep it: changing it later means changing it inside the database
|
||||
# too. Letters and digits only — it goes into a URL, where `@ / : #` break it.
|
||||
#
|
||||
# openssl rand -base64 24 | tr -d '/+=' | head -c 32
|
||||
#
|
||||
# Stick to letters and digits. This value goes into a connection URL, so a `@`,
|
||||
# `/`, `:` or `#` in it will be misparsed as URL structure rather than password.
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# --- optional ---------------------------------------------------------------
|
||||
|
||||
# Which build to run.
|
||||
#
|
||||
# latest tracks the `main` branch — the production line (default)
|
||||
# dev tracks the `dev` branch — newer, less settled
|
||||
# <commit sha> pins one exact build; every push publishes one, and this is
|
||||
# the rollback lever when an upgrade misbehaves
|
||||
#
|
||||
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
||||
# check which branch it actually landed on before assuming a bug.
|
||||
# Which build: `latest` (the `main` branch, default), `dev` (newer, less settled),
|
||||
# or a commit sha on `main` to pin one exact build — the rollback lever.
|
||||
#INKWELL_TAG=latest
|
||||
|
||||
# The host port the app is published on.
|
||||
#INKWELL_PORT=5000
|
||||
|
||||
# Which interface to bind. The default (all interfaces) is what lets desktop
|
||||
# clients on your network reach the server. Behind a reverse proxy, set this to
|
||||
# 127.0.0.1 so only the proxy can talk to it.
|
||||
# Which interface to bind. Behind a reverse proxy, set 127.0.0.1 so only the proxy
|
||||
# can reach the app.
|
||||
#INKWELL_BIND=0.0.0.0
|
||||
|
||||
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
|
||||
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
||||
# reverse proxy terminating HTTPS) and belongs there because it is something you may
|
||||
# need to change while the server is running, alongside the sign-in limits.
|
||||
# How many proxies sit in front is a setting, not an env var: Settings → Security →
|
||||
# "Trusted proxy hops" (default 1).
|
||||
|
||||
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
||||
# accounts, device tokens issued) are logged at INFO and read with
|
||||
# `docker compose logs app`.
|
||||
# How much the app logs. Credential events (sign-ins, failures, throttles, device
|
||||
# tokens issued) log at INFO; read them with `docker compose logs app`.
|
||||
#INKWELL_LOG_LEVEL=INFO
|
||||
|
||||
# Database identity. Changing these AFTER the first start does not rename anything
|
||||
@@ -70,14 +56,13 @@ POSTGRES_PASSWORD=
|
||||
#POSTGRES_USER=thoughtsync
|
||||
#POSTGRES_DB=thoughtsync
|
||||
#
|
||||
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
|
||||
# names are no longer read.
|
||||
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_*; the old names
|
||||
# aren't read.
|
||||
|
||||
# --- a note on HTTPS --------------------------------------------------------
|
||||
#
|
||||
# The app marks its session cookie Secure automatically when a request arrives over
|
||||
# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed.
|
||||
# The session cookie is marked Secure automatically when a request arrives over
|
||||
# HTTPS, directly or via a proxy setting X-Forwarded-Proto.
|
||||
#
|
||||
# Worth knowing if you use the desktop app: typing a bare hostname there defaults to
|
||||
# https://, deliberately, so a device token never crosses the wire in cleartext by
|
||||
# accident. Serving over plain HTTP means typing the `http://` yourself.
|
||||
# The desktop and Android apps read a bare hostname as https://, so a server on
|
||||
# plain HTTP has to be typed with its `http://`.
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
name: Android
|
||||
|
||||
# The native Kotlin/Compose client over the shared Rust core (M12).
|
||||
#
|
||||
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
||||
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
||||
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
|
||||
# uniffi.
|
||||
# The native Kotlin/Compose client over the shared Rust core. The store and sync
|
||||
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through uniffi.
|
||||
#
|
||||
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
||||
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
||||
@@ -15,12 +11,12 @@ name: Android
|
||||
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
|
||||
# signed with different keys cannot replace one another, and bridging that gap
|
||||
# means uninstalling first — which deletes the app's database and every local note
|
||||
# with it (Scribe issue 2803).
|
||||
# with it.
|
||||
|
||||
on:
|
||||
push:
|
||||
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
||||
# See desktop.yml for why, and 85ead4d for what the duplication cost.
|
||||
# See desktop.yml for why.
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,15 +28,15 @@ env:
|
||||
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
||||
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
||||
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
||||
# gradle.properties is not enough on its own (Minstrel hit the same thing).
|
||||
# gradle.properties is not enough on its own.
|
||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||
|
||||
jobs:
|
||||
# Does the APK need rebuilding, or is the channel already serving this source?
|
||||
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
|
||||
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
|
||||
# See the equivalent job in desktop.yml — same reasoning, the same one file set in
|
||||
# `packaging/version.sh`.
|
||||
#
|
||||
# The guard runs here so it covers the skip path too (§6.3).
|
||||
# The guard runs here so it covers the skip path too.
|
||||
#
|
||||
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
|
||||
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
||||
@@ -74,12 +70,12 @@ jobs:
|
||||
|
||||
# The core ships inside the APK (through android/ffi), so its checks have to gate
|
||||
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
|
||||
# there cannot stop this lane publishing (rule 177, #5237).
|
||||
# there cannot stop this lane publishing.
|
||||
#
|
||||
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
|
||||
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
|
||||
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
|
||||
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
|
||||
# image has OpenSSL vendored for the Android targets alone, so `openssl-sys` fails
|
||||
# there. Same Rust pin, same Cargo.lock.
|
||||
rust:
|
||||
name: Core and FFI clippy and tests
|
||||
needs: [decide]
|
||||
@@ -100,11 +96,11 @@ jobs:
|
||||
name: Kotlin + Rust (APK)
|
||||
needs: [decide, rust]
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||
# runs-on is only a scheduling label. flutter-ci is the
|
||||
# proven-working label that can pull our container images.
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
|
||||
# Rust + the four
|
||||
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
||||
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||
|
||||
@@ -119,7 +115,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Derives a version, so it needs the whole history — see the note in
|
||||
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
||||
# desktop.yml. Depth-1 is silently wrong here, not loudly broken.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Gradle and Cargo
|
||||
@@ -143,7 +139,7 @@ jobs:
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
||||
# TWO CLOCKS, ON PURPOSE. The NAME answers "is this the
|
||||
# same code?", so it comes from the COMMIT and a dev build and the main
|
||||
# build of one commit read identically. The CODE answers "may this be
|
||||
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
||||
@@ -161,7 +157,7 @@ jobs:
|
||||
echo "label=release" >> $GITHUB_OUTPUT
|
||||
# An optimised .so for the APK people install. The cargoNdk task keeps
|
||||
# the symbols uniffi reads its metadata from, which the release
|
||||
# profile would strip (run 4077, Scribe #2810).
|
||||
# profile would strip.
|
||||
echo "profile=release" >> $GITHUB_OUTPUT
|
||||
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
|
||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||
@@ -190,7 +186,7 @@ jobs:
|
||||
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
|
||||
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
|
||||
# (M12 step 3) precisely so this lane needs no second image, and going
|
||||
# precisely so this lane needs no second image, and going
|
||||
# through Gradle plugins would mean a second version of each tool resolved
|
||||
# at build time and kept in lockstep with the image's by hand.
|
||||
#
|
||||
@@ -210,7 +206,7 @@ jobs:
|
||||
#
|
||||
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
||||
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
||||
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
||||
# not exist. It costs one extra Kotlin compile and buys the
|
||||
# type-check on the debug variant, which is the one an emulator build
|
||||
# would use.
|
||||
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
@@ -233,16 +229,15 @@ jobs:
|
||||
# simply refuses to install over the app already on the phone — a failure
|
||||
# that otherwise only shows up on the device, after the run is green.
|
||||
#
|
||||
# And FAILS unless there is exactly one signer and it is the release key
|
||||
# (family idea #5103, practice 3). Printing alone was not a check: a build
|
||||
# signed with any other key would have gone on to be staged and published,
|
||||
# and every phone would have refused it. The steps that publish come after
|
||||
# this one in the same job, so a failure here stops them.
|
||||
# And FAILS unless there is exactly one signer and it is the release key.
|
||||
# Printing alone is not a check: a build signed with any other key would go on
|
||||
# to be staged and published, and every phone would refuse it. The steps that
|
||||
# publish come after this one in the same job, so a failure here stops them.
|
||||
- name: Check the signing certificate
|
||||
if: steps.build.outputs.keystore != ''
|
||||
env:
|
||||
# The release certificate's SHA-256, as apksigner printed it for the
|
||||
# signed APK in run 8753 (CN=Bryan Van Deusen, O=fabledsword). Every
|
||||
# The release certificate's SHA-256, as apksigner prints it for a signed
|
||||
# APK (CN=Bryan Van Deusen, O=fabledsword). Every
|
||||
# installed copy carries this certificate. It only changes if the key
|
||||
# does, and then every install has to be replaced by hand anyway.
|
||||
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
|
||||
@@ -285,10 +280,9 @@ jobs:
|
||||
# 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.
|
||||
# Publishing `stable` from main 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`.
|
||||
- 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: .
|
||||
@@ -300,18 +294,17 @@ jobs:
|
||||
*) 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.
|
||||
# (packaging/channel-tag.sh). A tag named `dev` would shadow 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
|
||||
|
||||
- name: Upload the APK
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||
# success while Gitea serves artifacts back only through the v4 API, so the
|
||||
# upload is stored and invisible (Scribe 2270).
|
||||
# Stock action: it works on this forge because gitea/runner 3.x edits
|
||||
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
||||
# @v3 — it reports success while Gitea serves artifacts back only through the
|
||||
# v4 API, so the upload is stored and invisible.
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# The APK's variant, NOT the Cargo profile. They are the same word for
|
||||
@@ -338,11 +331,8 @@ jobs:
|
||||
# from here — that is the other half of this.
|
||||
#
|
||||
# No `always()`: a red core check or APK build means no image for this
|
||||
# commit (rule 177, #5419). This used to run regardless, on the reasoning that
|
||||
# a broken Android lane shouldn't block server delivery, and the image would
|
||||
# bake in the previous client. Run 8839 is what that meant: `:<sha>` published
|
||||
# for 0ecdbfe while its APK lane (8835) was red, holding a client that wasn't
|
||||
# from that commit. The previous `:dev` stays where it is; fix forward.
|
||||
# commit. Building regardless would publish a `:<sha>` holding a client that
|
||||
# isn't from that commit. The previous `:dev` stays where it is; fix forward.
|
||||
#
|
||||
# Not skipped on tags: every ref that builds an image needs the call, since
|
||||
# ci.yml stands down on these pushes and nothing else builds one.
|
||||
|
||||
+47
-63
@@ -3,22 +3,21 @@
|
||||
# Push to dev: typecheck + lint + test + build :dev
|
||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||
#
|
||||
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
||||
# nothing pins one — verified by looking for a consumer, not for whether one is
|
||||
# imaginable — and the git release tag is a different object in a different system
|
||||
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
|
||||
# THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing
|
||||
# pins one, and the git release tag is a different object in a different system.
|
||||
# The image is addressed by CHANNEL or by COMMIT; the release by date.
|
||||
#
|
||||
# A `v*` tag builds nothing at all. The merge to main already published everything,
|
||||
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
|
||||
# which rule 145 forbids even when they match.
|
||||
# and a tag rebuilding that same source would re-push :<sha>, which a published tag
|
||||
# never allows.
|
||||
#
|
||||
# main is the production line, so a merge moves :latest to its tip (family rule 46)
|
||||
# — no version release required. :<sha> is the immutable rollback unit, and it is
|
||||
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
|
||||
# accumulating forever, for a channel whose entire contract is that it moves.
|
||||
# main is the production line, so a merge moves :latest to its tip — no version
|
||||
# release required. :<sha> is the immutable rollback unit, and it is on main ONLY: a
|
||||
# sha tag per dev push is a rollback target nobody pulls, accumulating forever, for
|
||||
# a channel whose entire contract is that it moves.
|
||||
#
|
||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||
# REGISTRY_TOKEN -- forge PAT with write:packages scope
|
||||
# The registry username is derived from github.repository_owner (public — it's in
|
||||
# the image path), so no REGISTRY_USER secret is needed.
|
||||
name: CI & Build
|
||||
@@ -28,13 +27,12 @@ on:
|
||||
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
||||
# the image ALWAYS builds. Two reasons:
|
||||
#
|
||||
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
|
||||
# production commit is addressable. A path filter quietly broke that promise
|
||||
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
|
||||
# * Every push to `main` publishes a `:<sha>`, so any production commit is
|
||||
# addressable. A path filter would break that for a docs-only merge: no
|
||||
# trigger, no image, no sha tag for that commit.
|
||||
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
|
||||
# is a floating tag and this can face the internet), and building every push
|
||||
# picks those updates up. That is why note 3127 §4's base tension does not
|
||||
# bite here — the one artifact it would apply to never skips.
|
||||
# picks those updates up.
|
||||
#
|
||||
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
|
||||
# for the clients, which is why THEY skip and this does not.
|
||||
@@ -62,17 +60,17 @@ jobs:
|
||||
#
|
||||
# A push touching the Android app runs BOTH workflows at once. Building here
|
||||
# would bake in the PREVIOUS client and then, when the new one landed, there
|
||||
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
|
||||
# would be no second build — `:<sha>` is the immutable rollback unit
|
||||
# and rebuilding it with different content would make it neither.
|
||||
#
|
||||
# So on such a push this workflow stands down, and the Android lane dispatches it
|
||||
# when it is finished. Exactly one image per commit, containing the client from
|
||||
# that commit.
|
||||
#
|
||||
# The path list below MUST match android.yml's trigger. Two places holding one
|
||||
# decision is the recurring failure in this repo (issues 2181-2183); it is here
|
||||
# because a workflow cannot read another's filters, and it is a `git diff` rather
|
||||
# than a config so at least it is inspectable in the log.
|
||||
# The path list below MUST match android's file set in packaging/version.sh. Two
|
||||
# places holding one decision drift apart; it is here because this gate needs the
|
||||
# answer before the Android lane has decided, and it is a `git diff` rather than a
|
||||
# config so at least it is inspectable in the log.
|
||||
gate:
|
||||
name: Build now, or wait for Android?
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
@@ -125,11 +123,10 @@ jobs:
|
||||
echo "Changed in this push:"
|
||||
echo "$changed" | sed 's/^/ /'
|
||||
|
||||
# MUST match android's file set in packaging/version.sh. `packaging/` was
|
||||
# missing here after step 4 added it there — so a packaging-only push had
|
||||
# the Android lane rebuild and dispatch while this gate ALSO let the image
|
||||
# build, producing two images for one commit and, on main, a second push of
|
||||
# the same :<sha> with different bytes. Rule 145's exact prohibition.
|
||||
# MUST match android's file set in packaging/version.sh. A path missing here
|
||||
# lets a push touching only it rebuild and dispatch from the Android lane
|
||||
# while this gate ALSO lets the image build: two images for one commit and,
|
||||
# on main, a second push of the same :<sha> with different bytes.
|
||||
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
||||
echo ""
|
||||
echo "This push also changes the Android client. Standing down: the"
|
||||
@@ -145,8 +142,8 @@ jobs:
|
||||
|
||||
# The web's whole verification: the type check, then its unit tests. Both in the
|
||||
# job `build` already needs, so a red web test blocks the image like any other
|
||||
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
|
||||
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
|
||||
# lane. Among other things they guard the web copy of the note grammar, which is
|
||||
# mirrored in Rust and Python.
|
||||
typecheck:
|
||||
name: Web typecheck and unit tests
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
@@ -203,17 +200,16 @@ jobs:
|
||||
# and runs in the job below.
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||
|
||||
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
|
||||
# for the first time when the operator's container started — 26 revisions, none of
|
||||
# them ever executed by CI — and the schema the migrations build had never been
|
||||
# checked against the models that read it.
|
||||
# Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs
|
||||
# it, and the schema the migrations build is checked against the models that read
|
||||
# it.
|
||||
#
|
||||
# Gates the build, along with every other lane — see the `build` job's `needs`.
|
||||
#
|
||||
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
|
||||
# Job key stays separator-free ("integration") with no `name:`. act_runner
|
||||
# derives the service-container name from the truncated job display name, and the
|
||||
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
||||
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
||||
# on this runner, so the step resolves the container's bridge IP.
|
||||
integration:
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
@@ -251,8 +247,8 @@ jobs:
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
# Only THIS job's services. The runner's docker daemon is shared, so another
|
||||
# repo's job can be running beside this one, and a job-name filter matches its
|
||||
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
|
||||
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
||||
# containers too, and would take another repo's Postgres. act names every
|
||||
# container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
||||
# <n> from our own container (its id is in the /etc/hostname bind mount) and
|
||||
# require exactly one match.
|
||||
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||
@@ -264,7 +260,7 @@ jobs:
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
|
||||
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
||||
# Wait for Postgres to accept connections. `run:` is busybox sh —
|
||||
# no bash /dev/tcp — so use the Python that is always present here.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
@@ -277,7 +273,7 @@ jobs:
|
||||
else:
|
||||
sys.exit("postgres did not become reachable")
|
||||
PY
|
||||
# Real migrations build the schema, never metadata.create_all (rule 82) —
|
||||
# Real migrations build the schema, never metadata.create_all —
|
||||
# testing a schema no deployment has ever seen would prove nothing. This
|
||||
# step IS the migration test: a broken revision fails the job here.
|
||||
/opt/venv/bin/alembic upgrade head
|
||||
@@ -285,15 +281,9 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# Every lane gates the build. This once stopped at lint + typecheck, on the
|
||||
# reasoning that DB-backed testing happened manually against the dev image
|
||||
# rather than on every push — true until 6f21db8 added the integration lane,
|
||||
# and false since.
|
||||
#
|
||||
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
|
||||
# anyway, so the deployed server ran a build whose test lane was red. An image
|
||||
# tag is the rollback substrate (family rule 46); one that can be published
|
||||
# from a failing run is not a substrate you can roll back TO.
|
||||
# Every lane gates the build. An image tag is the rollback substrate, and one
|
||||
# that can be published from a failing run is not a substrate you can roll back
|
||||
# TO.
|
||||
needs: [gate, typecheck, lint, test, integration]
|
||||
if: needs.gate.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
@@ -306,22 +296,20 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
||||
# and produces a too-low value silently, with the lane green (§6.1).
|
||||
# and produces a too-low value silently, with the lane green.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
||||
# run: steps execute under busybox sh, so use POSIX `case`,
|
||||
# NOT bash `[[ ]]`.
|
||||
run: |
|
||||
# The image's version is DERIVED from its own shipped files — including the
|
||||
# Android client it bakes in, which is why an APK-only change re-versions
|
||||
# it. One value and no ordering key: nothing compares a server image, so
|
||||
# §2 says do not invent one just because the other artifacts have one.
|
||||
# it. One value and no ordering key: nothing compares a server image.
|
||||
#
|
||||
# This was a short sha on main and the literal "dev" elsewhere, which could
|
||||
# not answer "how old is this instance?" — the question that actually gets
|
||||
# asked of a self-hosted app running in several places.
|
||||
# Date-shaped, because "how old is this instance?" is the question that
|
||||
# actually gets asked of a self-hosted app running in several places.
|
||||
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
@@ -345,11 +333,11 @@ jobs:
|
||||
|
||||
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||
# app for their machine from the server holding their notes — without an
|
||||
# account on this forge, which is private (issue 2091) and is why serving them
|
||||
# from a release page was never an option for anybody but the operator.
|
||||
# account on this forge, which is private, so its release pages serve nobody
|
||||
# but the forge's own users.
|
||||
#
|
||||
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||
# the price of the product being complete (rule 23), and the AppImage is not
|
||||
# the price of the product being complete, and the AppImage is not
|
||||
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||
# so a server without one cannot serve in-app updates to anyone.
|
||||
#
|
||||
@@ -367,12 +355,8 @@ jobs:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||
# :latest serves stable ones. 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, and the reason the channel is chosen here
|
||||
# rather than inside the script: the caller is what knows which image it is
|
||||
# building.
|
||||
# :latest serves stable ones. The channel is chosen here rather than inside
|
||||
# the script because the caller is what knows which image it is building.
|
||||
case "${{ github.ref_name }}" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
|
||||
@@ -4,22 +4,20 @@
|
||||
# It DOES run on frontend changes. tauri's generate_context! embeds the built
|
||||
# frontend in the binary, so a frontend commit that never triggers this ships to
|
||||
# the web and silently never reaches the desktop app — and desktop, web and Android
|
||||
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
|
||||
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
|
||||
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
|
||||
# isn't there.
|
||||
# are peer surfaces held to one quality bar, not a primary and its fallbacks. A
|
||||
# build takes 4-5 minutes, so there is no cost worth narrowing that for.
|
||||
#
|
||||
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
|
||||
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
|
||||
# runs-on is just a registered scheduling label, not a per-purpose
|
||||
# runner. The frontend is built here because tauri's generate_context! embeds it.
|
||||
name: Desktop (Tauri)
|
||||
|
||||
on:
|
||||
push:
|
||||
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
|
||||
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
|
||||
# from it within a day (85ead4d). The `decide` job below reads the real set and
|
||||
# skips in seconds when nothing moved — one definition, one reader (§3).
|
||||
# NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's
|
||||
# file set beside the one in `packaging/version.sh`, and the two drift. The
|
||||
# `decide` job below reads the real set and skips in seconds when nothing moved —
|
||||
# one definition, one reader.
|
||||
#
|
||||
# The cost is that this workflow starts on every push rather than on a matching
|
||||
# one. That is a ~15s container for a decision, against a lane that cannot
|
||||
@@ -40,13 +38,9 @@ permissions:
|
||||
jobs:
|
||||
# Does anything need building at all?
|
||||
#
|
||||
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
|
||||
# the `paths:` filters that used to state the same fact a second time. They drifted
|
||||
# from it within a day: `packaging/` was added to the sets and not to the filters,
|
||||
# so the commit fixing a derivation bug never ran on the two lanes it fixed
|
||||
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
|
||||
# ONE reader of ONE definition: the file sets in `packaging/version.sh`.
|
||||
#
|
||||
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
|
||||
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one.
|
||||
# Skipping because "the channel already serves this version" is indistinguishable
|
||||
# from "we derived a stale value that happens to match" unless something checks.
|
||||
decide:
|
||||
@@ -60,7 +54,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — depth-1 is silently wrong (§6.1).
|
||||
# Derives a version — depth-1 is silently wrong.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide
|
||||
@@ -76,13 +70,12 @@ jobs:
|
||||
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||
|
||||
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
|
||||
# them (rule 177: nothing publishes on red).
|
||||
# them: nothing publishes on red.
|
||||
#
|
||||
# They used to be steps inside `build`, which gated the Linux publish and nothing
|
||||
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
|
||||
# stopped, and the Windows installer built and published to the dev release in the
|
||||
# same minute (#5184). An edge from each publisher to this job is the gate; a
|
||||
# verdict inside a sibling job is only a report.
|
||||
# As steps inside `build` they would gate the Linux publish and nothing else, and
|
||||
# the Windows installer would publish beside a failed clippy. An edge from each
|
||||
# publisher to this job is the gate; a verdict inside a sibling job is only a
|
||||
# report.
|
||||
#
|
||||
# Both publishers need it directly rather than through each other, so a Windows
|
||||
# failure still never blocks the Linux bundles, and neither waits on the other's
|
||||
@@ -108,25 +101,20 @@ jobs:
|
||||
|
||||
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
|
||||
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
|
||||
# a red web test would still let both installers publish (rule 177).
|
||||
# a red web test would still let both installers publish.
|
||||
- name: Web unit tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
|
||||
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
|
||||
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
|
||||
# re-resolving. Both publishing jobs need this job, so neither
|
||||
# bundles a commit whose lockfile drifted.
|
||||
#
|
||||
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
||||
#
|
||||
# These three steps used to run inside the desktop crate, which was right when
|
||||
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
|
||||
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
|
||||
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
|
||||
# all. Both crates are dependencies of the desktop, so they still COMPILED —
|
||||
# which is exactly why the gap was invisible, and why a green run kept meaning
|
||||
# less than it looked like it meant.
|
||||
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri. Inside the
|
||||
# desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not
|
||||
# run and nothing would lint the Android uniffi shim, while both still COMPILE
|
||||
# as dependencies — so the run stays green and the gap stays invisible.
|
||||
- name: Clippy
|
||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
|
||||
@@ -148,8 +136,8 @@ jobs:
|
||||
name: Tauri desktop (Linux)
|
||||
needs: [decide, verify]
|
||||
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
|
||||
# runs to the runner's own 30-minute cap, as run 8830 did on a rust-lld link whose
|
||||
# threads had all deadlocked (#5409). Kept under that cap so this one is the limit
|
||||
# runs to the runner's own 30-minute cap, as a rust-lld link whose threads have
|
||||
# all deadlocked will. Kept under that cap so this one is the limit
|
||||
# that fires, whatever the runner is configured with.
|
||||
timeout-minutes: 20
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
@@ -166,7 +154,7 @@ jobs:
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# the lane green — the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
@@ -180,10 +168,10 @@ jobs:
|
||||
|
||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||
#
|
||||
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
||||
# tauri FAILS the build if it's asked to produce updater artifacts with no key,
|
||||
# so making it conditional is what lets the pipeline stay green before the
|
||||
# operator has added the secret. With the key present, each bundle gets a
|
||||
# createUpdaterArtifacts is applied only when a signing key exists: tauri
|
||||
# FAILS the build if it's asked to produce updater artifacts with no key, so
|
||||
# making it conditional is what lets the pipeline stay green without the
|
||||
# secret. With the key present, each bundle gets a
|
||||
# `.sig` beside it — the file the updater actually verifies against.
|
||||
- name: Tauri build (deb + AppImage)
|
||||
env:
|
||||
@@ -200,10 +188,10 @@ jobs:
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
# separate value, exported just below.
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# The DISPLAY version, baked into the binary by `option_env!`.
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
@@ -219,7 +207,7 @@ jobs:
|
||||
|
||||
# Tauri's AppImage bundles the build host's graphics/display libs
|
||||
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU
|
||||
# drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021).
|
||||
# drivers and abort to a black window (EGL_BAD_PARAMETER).
|
||||
# Strip that host-coupled stack so the app uses the running system's
|
||||
# graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
|
||||
# script resolves its own paths), overwriting the AppImage in place.
|
||||
@@ -253,9 +241,9 @@ jobs:
|
||||
# control file and cross-checks it against what the ELF actually needs
|
||||
# (dpkg-shlibdeps).
|
||||
#
|
||||
# We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed
|
||||
# tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so
|
||||
# declaring them again only produced a control file listing each twice.
|
||||
# We deliberately do NOT set bundle.linux.deb.depends: tauri already infers
|
||||
# exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only
|
||||
# produces a control file listing each twice.
|
||||
# This step is the guard instead — if tauri's inference ever stops
|
||||
# covering what the binary links, the build fails here.
|
||||
- name: Verify the .deb
|
||||
@@ -271,13 +259,12 @@ jobs:
|
||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||
|
||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
|
||||
# something Gitea stores but will never serve back (it returns artifacts only
|
||||
# through the v4 API, which filters on content_encoding='application/zip').
|
||||
# No continue-on-error: a swallowed upload failure is exactly how 110
|
||||
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
||||
# Stock action: it works on this forge because gitea/runner 3.x edits
|
||||
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
||||
# @v3 — it uploads something Gitea stores but will never serve back (it returns
|
||||
# artifacts only through the v4 API, which filters on
|
||||
# content_encoding='application/zip'). No continue-on-error: a swallowed upload
|
||||
# failure lets unreachable artifacts pile up unnoticed. Fail loudly instead.
|
||||
- name: Upload bundles
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -292,14 +279,11 @@ jobs:
|
||||
|
||||
# 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"
|
||||
# read — the forge 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.
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
|
||||
# reaches stable users with no tag cut.
|
||||
#
|
||||
# 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
|
||||
@@ -315,7 +299,7 @@ jobs:
|
||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||
exit 0
|
||||
fi
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
|
||||
# `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
|
||||
@@ -323,7 +307,7 @@ jobs:
|
||||
*) 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.
|
||||
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
|
||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing to the $RELEASE_TAG channel."
|
||||
@@ -355,7 +339,7 @@ jobs:
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# the lane green — the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
@@ -396,10 +380,10 @@ jobs:
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
# separate value, exported just below.
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||
# The DISPLAY version, baked into the binary by `option_env!`.
|
||||
# A different value for a different audience: this is the one a person
|
||||
# quotes in a bug report, the key above is the one only a comparator
|
||||
# sees. Exported rather than passed as a flag because the macro that
|
||||
@@ -430,14 +414,11 @@ jobs:
|
||||
|
||||
# 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"
|
||||
# read — the forge 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.
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
|
||||
# reaches stable users with no tag cut.
|
||||
#
|
||||
# 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
|
||||
@@ -453,7 +434,7 @@ jobs:
|
||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||
exit 0
|
||||
fi
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
|
||||
# `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
|
||||
@@ -461,7 +442,7 @@ jobs:
|
||||
*) 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.
|
||||
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
|
||||
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||
export RELEASE_TAG RELEASE_PRERELEASE
|
||||
echo "Publishing to the $RELEASE_TAG channel."
|
||||
@@ -488,7 +469,7 @@ jobs:
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# the lane green — the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
@@ -519,7 +500,7 @@ jobs:
|
||||
# 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
|
||||
# No tag arm: 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) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
name: Release
|
||||
|
||||
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
|
||||
# A RELEASE BUILDS NOTHING. That is the whole point of this lane.
|
||||
#
|
||||
# The merge to `main` already published everything a user can receive: the server
|
||||
# image as `:latest` + `:<sha>`, 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
|
||||
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
|
||||
# `:<sha>`, which a published tag never allows.
|
||||
#
|
||||
# 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
|
||||
# what was in it. 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.
|
||||
# This lane never cuts a tag; it 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
|
||||
# Same string as the artifact version (`vYYYY.MM.DD.HHMM`), different
|
||||
# system.
|
||||
|
||||
on:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
# 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
|
||||
# wrote, 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
|
||||
|
||||
+7
-8
@@ -45,7 +45,7 @@ COPY client/ src/inkwell/client/
|
||||
|
||||
# The desktop installer, served at /install.sh with this server's own address
|
||||
# written into it (installer.py). The SAME script the forge serves raw, copied rather
|
||||
# than moved so the operator's documented forge URL keeps working.
|
||||
# than moved so the forge's raw URL keeps working.
|
||||
COPY desktop/packaging/install.sh src/inkwell/install.sh
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
@@ -55,16 +55,15 @@ ENV APP_VERSION=$BUILD_VERSION
|
||||
|
||||
EXPOSE 5000
|
||||
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
||||
# slow/unready database from crash-looping the container. Family convention
|
||||
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
||||
# slow or unready database from crash-looping the container. The schema is built by
|
||||
# real migrations, never metadata.create_all.
|
||||
#
|
||||
# Timeouts (family idea #5105, practice 9; read from hypercorn 0.18's source):
|
||||
# Timeouts, as hypercorn 0.18 implements them:
|
||||
# - `--keep-alive 120` is the idle timeout. It is also the header timeout:
|
||||
# hypercorn marks a connection busy only once a whole request has arrived, so a
|
||||
# client dribbling headers is still "idle" and is cut off at this. It was 600,
|
||||
# ten minutes per held connection. 120 stays above Traefik's 90s idle timeout
|
||||
# for backend connections, so the proxy never reuses one this server has just
|
||||
# closed (a 502).
|
||||
# client dribbling headers is still "idle" and is cut off at this. 120 stays
|
||||
# above Traefik's 90s idle timeout for backend connections, so the proxy never
|
||||
# reuses one this server has just closed (a 502).
|
||||
# - No `--read-timeout`, on purpose. It bounds every socket read, including the
|
||||
# whole time a download is streaming and the client sends nothing, so it would
|
||||
# cut off an APK fetched slowly over mobile data. Bodies are capped instead
|
||||
|
||||
@@ -1,84 +1,41 @@
|
||||
# Inkwell
|
||||
<p align="center">
|
||||
<img src="frontend/public/icon.svg" width="96" alt="Inkwell logo">
|
||||
</p>
|
||||
|
||||
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
||||
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
||||
under a second, designed to grow into a lightweight second brain (labels, search,
|
||||
`[[wiki-links]]`, graph).
|
||||
<h1 align="center">Inkwell</h1>
|
||||
|
||||
- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
|
||||
- **Auth:** native email + password (signed-cookie sessions). Multi-user with the
|
||||
family owner + direct-share + group-share ACL.
|
||||
- **Web-first.** An Android companion is a later, conditional milestone.
|
||||
<p align="center">Capture a thought in under a second, and find it again later.</p>
|
||||
|
||||
## Layout
|
||||
Inkwell is a self-hosted notes app built around a Keep-style board of cards. Type
|
||||
and it's saved; tag with `#words`, tick checklists, set reminders, attach files,
|
||||
and search or browse by date when you need something back. It runs in the browser,
|
||||
as a desktop app for Linux and Windows that works offline, and as an Android app,
|
||||
all syncing with your own server.
|
||||
|
||||
```
|
||||
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
|
||||
alembic/ async migrations (schema built via `alembic upgrade head`)
|
||||
tests/ DB-free unit tests (pytest)
|
||||
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
||||
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
|
||||
docker-compose.yml local app + Postgres stack
|
||||
```
|
||||
## Quick start
|
||||
|
||||
## Development
|
||||
|
||||
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
|
||||
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||
```
|
||||
|
||||
Frontend (proxies `/api` to `:5000`):
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Or run it in Docker. **Hot-reload dev stack** (edit code, changes reload live) —
|
||||
Postgres + backend + Vite dev server, app at http://localhost:5173:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Or the **full production image** (SPA baked in) at http://localhost:5000:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Deploy (self-host)
|
||||
|
||||
A complete two-container stack (app + Postgres) you can copy and run. Save it as
|
||||
`docker-compose.yml`, change the two `CHANGE_ME` passwords (they must match), then
|
||||
`docker compose up -d`:
|
||||
Save this as `docker-compose.yml`, replace both `CHANGE_ME`s with the same password,
|
||||
and run `docker compose up -d`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: inkwell
|
||||
POSTGRES_PASSWORD: CHANGE_ME # change this
|
||||
POSTGRES_PASSWORD: CHANGE_ME
|
||||
POSTGRES_DB: inkwell
|
||||
volumes:
|
||||
- inkwell-db:/var/lib/postgresql/data
|
||||
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
|
||||
restart: unless-stopped
|
||||
image: git.fabledsword.com/bvandeusen/inkwell:latest
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
||||
volumes:
|
||||
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
|
||||
- inkwell-data:/var/inkwell
|
||||
ports:
|
||||
- "5000:5000"
|
||||
|
||||
@@ -87,36 +44,117 @@ volumes:
|
||||
inkwell-data:
|
||||
```
|
||||
|
||||
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
||||
Open `http://<host>:5000` and register. **The first account becomes the admin.**
|
||||
|
||||
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
|
||||
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
||||
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
|
||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
|
||||
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
|
||||
no version-shaped tags: nothing pins one, and the build reports its own version
|
||||
at `/api/config` and `/health`.
|
||||
- **Putting it on the public internet:** there are four things to do first — close
|
||||
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
|
||||
port, and back up the attachment volume as well as the database. See
|
||||
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
|
||||
hardens on its own and what it deliberately doesn't.
|
||||
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
|
||||
browser's install button) for an app-like window. Browsers only offer install over a
|
||||
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
||||
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
||||
The repository's own [docker-compose.yml](docker-compose.yml) is the same stack read
|
||||
from a `.env` file; copy [.env.example](.env.example) to `.env` to use it.
|
||||
|
||||
## Milestones
|
||||
## Configuration
|
||||
|
||||
- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth,
|
||||
sharing-ACL spine, Fabled-Git CI.
|
||||
- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin,
|
||||
edit-in-place, archive, trash+restore).
|
||||
- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI,
|
||||
`DATABASE_URL` is the only required env.
|
||||
- M2 — Organize: labels/tags, search, checklists, attachments.
|
||||
- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders.
|
||||
- M4 — Launch hardening: responsive/PWA, settings UI, polish.
|
||||
| Variable | Required | What it is |
|
||||
|---|---|---|
|
||||
| `INKWELL_DATABASE_URL` | yes | The Postgres connection URL. |
|
||||
| `INKWELL_SECRET_KEY` | no | Signs sessions. Unset, one is generated and kept in the database. |
|
||||
| `INKWELL_LOG_LEVEL` | no | `INFO` by default. Sign-ins and other credential events log at INFO. |
|
||||
|
||||
Work happens on `dev`; `main` is protected (PR-only).
|
||||
Everything else — site name, sign-ups, email, attachment limits, trash retention,
|
||||
link previews, backups, proxy hops — is set by the admin in **Settings**.
|
||||
|
||||
## Before you put it on the internet
|
||||
|
||||
Close registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing
|
||||
the app port directly, and back up the attachment volume as well as the database.
|
||||
[docs/public-hosting.md](docs/public-hosting.md) walks through each one, and lists
|
||||
what the app already hardens on its own.
|
||||
|
||||
The browser only offers to install Inkwell as an app (PWA) over HTTPS or `localhost`.
|
||||
|
||||
## Updating and backups
|
||||
|
||||
- **Update:** `docker compose pull && docker compose up -d`. Migrations run on start.
|
||||
- **Roll back:** pin `image: …/inkwell:<commit-sha>`. Every build on `main` publishes one.
|
||||
- **Back up:** turn on scheduled backups in **Settings → Backups** (a folder, an
|
||||
S3-compatible bucket, or Fabled Archives). See [docs/backups.md](docs/backups.md).
|
||||
Otherwise back up both volumes: `inkwell-db` and `inkwell-data`.
|
||||
|
||||
## Features
|
||||
|
||||
**Capturing**
|
||||
- A masonry board of cards. Start typing anywhere on it to write a new note.
|
||||
- Notes save as you type; there is no save button.
|
||||
- Checklists inside a note, ticked straight from the card.
|
||||
- Images and files attached to a note, shown on its card; click an image to see it
|
||||
full size or copy it back out.
|
||||
- Paste a link and it gets a preview card.
|
||||
- On the desktop, a global hotkey opens a small capture window over whatever you're doing.
|
||||
- On Android, share text or images into Inkwell from any app.
|
||||
|
||||
**Organising**
|
||||
- `#tags` written in the text become tags; a line of tags on its own is tidied away.
|
||||
- Each tag gets a colour, and tags can be renamed, recoloured, merged and deleted.
|
||||
- Pin, archive, drag to reorder.
|
||||
- Trash with undo, emptied after a retention period the admin sets.
|
||||
|
||||
**Finding**
|
||||
- Full-text search, combined with filters for tags, attachments and notes shared with you.
|
||||
- A timeline that groups notes by when you wrote them.
|
||||
- A command palette that jumps to any note by name.
|
||||
- Version history: every editing session keeps the note as it was before.
|
||||
|
||||
**Remembering**
|
||||
- Reminders, one-off or repeating, with Done and snooze from the card or the
|
||||
notification.
|
||||
- The desktop and Android apps notify you when the app isn't in front.
|
||||
|
||||
**Sharing and accounts**
|
||||
- Share a note with a person or a group, to view or to edit.
|
||||
- Invites, groups, per-account storage limits and an activity log for the admin.
|
||||
- Password reset by email, or by a link the admin makes.
|
||||
- Export everything as a zip, and import an Inkwell export or a Google Keep Takeout.
|
||||
|
||||
**Every device**
|
||||
- Web app, installable as a PWA.
|
||||
- Desktop app for Linux (`.deb`, Arch package, AppImage) and Windows. It keeps its
|
||||
own local store, works with no server at all, and syncs once you link one. Install
|
||||
it from your server's Account page, or with `curl -fsSL https://<your-server>/install.sh | sh`.
|
||||
- Android app, downloaded from your server and updated by it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| What you see | Why | Fix |
|
||||
|---|---|---|
|
||||
| The app container restarts with a database error at startup | Postgres isn't up yet, or the two passwords differ | Wait a minute; check both `CHANGE_ME`s match |
|
||||
| No "Install app" option in the browser | The page isn't on HTTPS | Serve it over HTTPS, or open it on `localhost` |
|
||||
| The desktop or Android app can't reach a plain-HTTP server | A bare hostname is read as `https://` | Type the address with `http://` |
|
||||
| A deployment upgraded from ThoughtSync shows no notes | It's pointed at new, empty volumes | Set `INKWELL_DB_VOLUME` and `INKWELL_DATA_VOLUME`; see [.env.example](.env.example) |
|
||||
|
||||
## Development
|
||||
|
||||
Backend (needs Postgres at `INKWELL_DATABASE_URL`):
|
||||
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||
```
|
||||
|
||||
Frontend, proxying `/api` to `:5000`: `cd frontend && npm install && npm run dev`
|
||||
(http://localhost:5173). Or run everything with hot reload:
|
||||
`docker compose -f docker-compose.dev.yml up`.
|
||||
|
||||
| Path | What's there |
|
||||
|---|---|
|
||||
| `src/inkwell/` | The Quart server |
|
||||
| `alembic/` | Database migrations |
|
||||
| `frontend/` | The Vue web app, also the desktop app's UI |
|
||||
| `core/` | The Rust store and sync engine the desktop and Android apps share |
|
||||
| `desktop/` | The Tauri desktop app and its packaging |
|
||||
| `android/` | The Kotlin/Compose Android app |
|
||||
|
||||
How devices sync is in [docs/sync.md](docs/sync.md); how the Android app reaches a
|
||||
server is in [docs/android-distribution.md](docs/android-distribution.md). Work
|
||||
happens on `dev`; `main` is the stable line.
|
||||
|
||||
## Licence
|
||||
|
||||
No licence has been chosen yet, so all rights are reserved.
|
||||
|
||||
+258
-374
@@ -3,317 +3,173 @@
|
||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||
> in the CI-Runner repo.
|
||||
|
||||
## Runtime image
|
||||
## Runtime images
|
||||
|
||||
```
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
```
|
||||
| Image | Used by |
|
||||
|---|---|
|
||||
| `git.fabledsword.com/bvandeusen/ci-python:3.14` | every `ci.yml` job; the `decide` jobs in `desktop.yml` and `android.yml` |
|
||||
| `git.fabledsword.com/bvandeusen/ci-tauri:1.97` | `desktop.yml` `verify`, `build`, `manifest`; `android.yml` `rust` |
|
||||
| `git.fabledsword.com/bvandeusen/ci-tauri-win:1.97` | `desktop.yml` `windows` |
|
||||
| `git.fabledsword.com/bvandeusen/ci-rust-android:1.97` | `android.yml` `build`, `server-image` |
|
||||
| `postgres:16-alpine` | service container for `ci.yml` `integration` |
|
||||
|
||||
Selected via `container.image` (not a `runs-on` label) on all four jobs in
|
||||
`.forgejo/workflows/ci.yml`: typecheck (Vue/TS), lint (ruff), test (pytest),
|
||||
build (docker buildx).
|
||||
Each is selected via `container.image`; `runs-on` is only a scheduling label.
|
||||
|
||||
The Rust pin is in LOCKSTEP across `ci-tauri`, `ci-tauri-win` and `ci-rust-android`.
|
||||
All three build `inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
|
||||
mismatched Rust minor would mean divergent resolution for no reason. Bump them
|
||||
together or not at all.
|
||||
|
||||
## Image deps used
|
||||
|
||||
- python 3.12+ (the runtime `Dockerfile` targets python:3.12-slim; tests run on
|
||||
the image's 3.14 — both >=3.12, so results stay representative)
|
||||
- node 24 — `npm ci` + `vue-tsc` in the typecheck job, and the frontend builder
|
||||
stage inside the production `Dockerfile`. (Also required by the JS-based
|
||||
`actions/checkout` action — a Node-less runner fails every job at checkout.)
|
||||
- ruff — lint job runs `ruff check src/` with zero install overhead
|
||||
- uv — test job creates the venv (`uv venv /opt/venv`) and installs the package
|
||||
with dev deps
|
||||
- docker CLI + buildx — build job pushes the dev/release image to the Fabled-Git
|
||||
registry
|
||||
**ci-python**
|
||||
- python 3.14 for the tests. The runtime `Dockerfile` targets `python:3.12-slim`;
|
||||
both are >=3.12, so results stay representative.
|
||||
- node 24 — `npm ci`, `vue-tsc` and the web unit tests. Also required by the JS-based
|
||||
`actions/checkout`: a Node-less runner fails every job at checkout. The
|
||||
`Dockerfile`'s own frontend stage uses `node:22-alpine`.
|
||||
- ruff — the lint job runs `ruff check src/` with no install.
|
||||
- uv — the test jobs create the venv (`uv venv /opt/venv`) and install the package
|
||||
with dev deps.
|
||||
- docker CLI + buildx — the build job pushes the image to the Fabled-Git registry.
|
||||
- curl — `packaging/fetch-clients.sh` and `packaging/guard-forward.sh`. The guard
|
||||
refuses to run without it, because every lookup would read as "nothing published".
|
||||
|
||||
**ci-tauri** — Rust, Node, WebKitGTK 4.1, the Tauri v2 Linux deps and `tauri-cli`.
|
||||
- **`libssl-dev` + `pkg-config` are load-bearing.** `inkwell-core`, which the desktop
|
||||
crate builds in, uses `reqwest` with the **`native-tls`** backend, which on Linux compiles against
|
||||
OpenSSL. Dropping either fails the build at `openssl-sys`. They are also Tauri's
|
||||
documented Linux prerequisites. This is why `android.yml`'s host-target `rust` job
|
||||
runs here and not on `ci-rust-android`, whose OpenSSL is vendored for the Android
|
||||
targets alone.
|
||||
- `dpkg-deb`, `dpkg-query`, `apt-cache` and `dpkg-shlibdeps` (from `dpkg-dev`, pulled
|
||||
in by `build-essential`) for `desktop/packaging/deb/verify.sh`; `tar` and a
|
||||
compressor for `desktop/packaging/arch/package-prebuilt.sh`.
|
||||
- Optional, used when present: `bsdtar` (`libarchive-tools`) for the pacman package's
|
||||
`.MTREE`, `zstd` (compression falls back zstd → xz → gzip), and a docker CLI for the
|
||||
`.deb` clean-container install test. `ci-tauri` has the first two, so the package
|
||||
ships as `.pkg.tar.zst` with an `.MTREE`. It has no docker CLI, so the install test
|
||||
is skipped; adding one would enable it, and its absence does not block a green
|
||||
build.
|
||||
|
||||
**ci-tauri-win** — Rust, Node, `cargo-xwin`, LLVM/`lld` and NSIS. A separate image
|
||||
per CI-Runner's fork rule: the MSVC CRT/SDK cache alone is >1 GB.
|
||||
|
||||
**ci-rust-android** — Rust, the four Android ABIs, `cargo-ndk`, SDK/NDK, JDK 25,
|
||||
`ktlint` and `detekt`. The NDK binutils symlinks and PATH append are there because NDK
|
||||
r23 removed the triple-prefixed binutils that autotools, and so vendored OpenSSL,
|
||||
invokes by bare name. JDK 25 requires **Gradle 9.1+** in the wrapper. The analyzers
|
||||
are the image's pinned CLIs rather than Gradle plugins, so there is one version of
|
||||
each.
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
Nothing installed at job time beyond what the image provides — all four jobs run
|
||||
entirely on `ci-python:3.14`.
|
||||
None. Every job runs entirely on its image.
|
||||
|
||||
## The lanes
|
||||
|
||||
**`ci.yml`** — on every push to `dev` and `main`, and when dispatched.
|
||||
`gate`, `typecheck`, `lint`, `test` and `integration` run in parallel, and `build`
|
||||
needs all five, so nothing publishes on red. `dev` publishes `:dev`; `main` publishes
|
||||
`:latest` and `:<sha>`. The image always builds: it is ~15 seconds, and building every
|
||||
push picks up `python:3.12-slim` updates.
|
||||
|
||||
`gate` stands the build down when the push touched the Android client's file set.
|
||||
The Android lane then dispatches `ci.yml` once it has published the APK, so the image
|
||||
bakes in the client from the same commit.
|
||||
|
||||
**`desktop.yml`** — on every push to `dev` and `main`.
|
||||
- `decide` reads the file set in `packaging/version.sh` and skips everything when the
|
||||
channel already serves this source. `packaging/guard-forward.sh` runs here, so it
|
||||
also covers the skip path.
|
||||
- `verify` builds the frontend, runs the web unit tests, then `cargo clippy --locked
|
||||
--workspace`, `cargo test --workspace` and `cargo fmt --all --check` from the repo
|
||||
root. Run from `desktop/src-tauri` instead, cargo would scope to the desktop package
|
||||
and the core's tests would silently not run. If you add a workspace member, check
|
||||
that it appears in the `cargo test` output.
|
||||
- `build` (Linux): `cargo tauri build` for `.deb` and AppImage, de-bundles the
|
||||
AppImage's graphics libs and re-signs it, verifies the `.deb`, repackages for pacman,
|
||||
and publishes to the channel. `APPIMAGE_EXTRACT_AND_RUN=1` is set because CI
|
||||
containers have no `/dev/fuse`.
|
||||
- `windows`: cross-compiles the NSIS installer. There is no Windows build host, and a
|
||||
Windows container cannot run on a Linux host. `.msi` needs WiX v3, a Windows
|
||||
program, so NSIS only.
|
||||
- `manifest` writes `latest.json` after both, so it describes both platforms.
|
||||
|
||||
**`android.yml`** — on every push to `dev` and `main`.
|
||||
`decide`, then `rust` (the core's clippy and tests, which gate the APK), then `build`
|
||||
(native library and bindings, ktlint, detekt, host-JVM unit tests, assemble, check
|
||||
the signing certificate, publish), then `server-image`, which dispatches `ci.yml`.
|
||||
|
||||
**`release.yml`** — on a `v*` tag. Builds nothing; it writes the changelog for the
|
||||
release.
|
||||
|
||||
## Notes
|
||||
|
||||
- **No `actions/cache`.** Deliberately omitted for npm/uv: it's a GitHub-fetched
|
||||
JS action and on a cold runner concurrent jobs race fetching it. We lean on the
|
||||
pinned `ci-python` image's pre-installed toolchain instead; `npm ci` / `uv pip
|
||||
install` cold cost is a non-blocker.
|
||||
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
|
||||
visibility but does not block the dev image push. DB-backed / integration tests
|
||||
run against the dev image manually — Inkwell's unit tests are DB-free (no
|
||||
Postgres service lane in CI yet).
|
||||
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
|
||||
(family rule 46).
|
||||
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
|
||||
representative of the deployed image.
|
||||
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
|
||||
```yaml
|
||||
uses: actions/upload-artifact@v7
|
||||
```
|
||||
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
||||
edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
|
||||
2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
|
||||
#3843). Until then this repo pinned a SHA mirror of the Forgejo project's
|
||||
fork, because upstream threw on the hostname before it opened a connection.
|
||||
- **No `actions/cache` for npm or uv.** It is a GitHub-fetched JS action, and on a
|
||||
cold runner concurrent jobs race fetching it. The pinned images' toolchains carry
|
||||
the load; cold `npm ci` / `uv pip install` cost is a non-blocker.
|
||||
- **Artifacts: stock `actions/upload-artifact@v7`, never `@v3`.** Stock works on this
|
||||
forge because gitea/runner 3.x edits the action's client-side `isGhes()` refusal out
|
||||
of its bundle (verified for upload-artifact v4–v7 and download-artifact v4–v8). `@v3`
|
||||
reports success, but Gitea serves artifacts back only through the v4 API
|
||||
(`content_encoding = application/zip`), so a v3 upload is stored and invisible to
|
||||
every retrieval path. Upload steps set `if-no-files-found: error` and carry no
|
||||
`continue-on-error`, so a failed or empty upload fails the run.
|
||||
|
||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
||||
stored but invisible to every retrieval path. A green job producing nothing
|
||||
retrievable.
|
||||
|
||||
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
|
||||
`continue-on-error`. They previously had both defaults inverted, which is how
|
||||
110 unreachable artifacts accumulated on this repo without anyone noticing —
|
||||
the upload could fail or match nothing and the run still went green. Scribe
|
||||
issues 2255 / 2270 have the full teardown.
|
||||
|
||||
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||
for the id (global run id, not the repo-scoped run number), then
|
||||
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
|
||||
`python3 -m zipfile -e`.
|
||||
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` for the
|
||||
id (the global run id, not the repo-scoped run number), then
|
||||
`…/actions/artifacts/{id}/zip`. Without `unzip`, use `python3 -m zipfile -e`.
|
||||
- **`libssl3` is covered transitively, on purpose — don't "fix" it.**
|
||||
`dpkg-shlibdeps` lists `libssl3` among the binary's needs, but the `.deb` declares
|
||||
only `libwebkit2gtk-4.1-0` + `libgtk-3-0`. `verify.sh` passes it because webkit's
|
||||
own dependency closure includes OpenSSL, so apt installs it either way. Declaring it
|
||||
would be *worse*: the name is release-dependent (`libssl3` on bookworm,
|
||||
`libssl3t64` after the 64-bit time_t transition in trixie/Ubuntu 24.04). If webkit
|
||||
ever stops pulling OpenSSL, `verify.sh` fails the build.
|
||||
- **The Windows TLS backend is chosen for that lane's sake.** `native-tls` on
|
||||
`x86_64-pc-windows-msvc` resolves to `schannel`, pure-Rust bindings to the OS TLS
|
||||
stack, which keeps C and assembly out of the cross-compile. `rustls` would pull in
|
||||
`ring`/`aws-lc-rs` and their assembler, the class of dependency that breaks this
|
||||
lane (`libsqlite3-sys` → `llvm-lib`). Treat a TLS-backend change as a change to the
|
||||
Windows lane.
|
||||
- **What CI cannot verify.** The runner is Debian, so the pacman package is not
|
||||
`pacman -U`-tested; the step logs `.PKGINFO` and the file listing instead. A Linux
|
||||
runner cannot launch a Windows binary or an APK, so green means *built*: a real
|
||||
Windows machine and a real phone are the check before trusting a release. Windows
|
||||
installers are unsigned and trip SmartScreen.
|
||||
|
||||
## The integration lane
|
||||
|
||||
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the
|
||||
operator's container started — 26 revisions, none of them ever executed by CI — and
|
||||
the schema the migrations build had never been checked against the models that read
|
||||
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
|
||||
watching.
|
||||
Real Postgres, so the schema the migrations build is checked against the models that
|
||||
read it before any deployment runs `alembic upgrade head`.
|
||||
|
||||
Copied from FabledScribe's `integration` job, which had already solved the awkward
|
||||
parts. Three of them are family rules for a reason:
|
||||
|
||||
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service
|
||||
container's name from the truncated job DISPLAY name, and the discovery step filters
|
||||
`docker ps` by it. A spaced or underscored name breaks the filter.
|
||||
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
||||
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
||||
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect.
|
||||
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
||||
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
||||
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||
|
||||
`postgres:16-alpine`, matching the production compose, so the schema is proven against
|
||||
the Postgres it will actually meet. The schema comes from **real migrations, never
|
||||
`metadata.create_all`** (rule 82): testing a schema no deployment has ever seen proves
|
||||
nothing, and that `alembic upgrade head` step IS the migration test — a broken revision
|
||||
fails the job there, before it can fail a container start.
|
||||
- **Job key `integration`, no `name:`.** act_runner derives the service container's
|
||||
name from the truncated job DISPLAY name, and the discovery step filters `docker ps`
|
||||
by it. A spaced or underscored name breaks the filter.
|
||||
- **Service hostnames are not routable** on this runner, so the step resolves the
|
||||
Postgres container's bridge IP and builds `INKWELL_DATABASE_URL` from it.
|
||||
`postgres:5432` will not connect. The runner's docker daemon is shared, so the
|
||||
lookup is scoped to this task's own containers.
|
||||
- **`run:` is busybox sh** — no `/dev/tcp` — so the readiness wait is a small Python
|
||||
heredoc. Its terminator must dedent to column 0 after YAML strips the block indent;
|
||||
check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||
- **Real migrations, never `metadata.create_all`.** The `alembic upgrade head` step IS
|
||||
the migration test: a broken revision fails the job there, before it can fail a
|
||||
container start.
|
||||
|
||||
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
|
||||
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
|
||||
BEFORE each test rather than after, so a failure leaves its rows behind to look at.
|
||||
|
||||
Like `test`, it runs for visibility and does **not** gate the build.
|
||||
## Checking lanes before pushing
|
||||
|
||||
There is no local way to run it — that would mean standing up Postgres on the
|
||||
workstation, which rule 12 reserves for an explicit request. This lane is verified in
|
||||
CI.
|
||||
The analyzers and tests below can run locally against the CI images, with the
|
||||
workflow's exact arguments, because a local check that differs from CI is worse than
|
||||
none. They reproduce CI exactly: same image, same lockfile, same units. Nothing else —
|
||||
no bundle builds, no services — is meant to run locally.
|
||||
|
||||
## Desktop (Tauri) lane — separate workflow
|
||||
|
||||
The Tauri desktop client (`desktop/`) builds in its own workflow,
|
||||
`.forgejo/workflows/desktop.yml`, NOT in `ci.yml` — it's a heavy Rust + AppImage
|
||||
build (~20–40 min) that should only run on `desktop/**` changes, not on every
|
||||
backend/frontend push.
|
||||
|
||||
- **Image:** `git.fabledsword.com/bvandeusen/ci-tauri:1.97` (Rust + Node +
|
||||
WebKitGTK 4.1 + Tauri v2 Linux deps + `tauri-cli`). Selected via
|
||||
`container.image`; `runs-on: python-ci` is only a scheduling label.
|
||||
- **Steps:** build the shared frontend (embedded by `generate_context!`) →
|
||||
`cargo tauri icon app-icon.png` (platform icon set from the committed 1024px
|
||||
source) → `cargo clippy --workspace -D warnings` → `cargo test --workspace` →
|
||||
`cargo fmt --all --check` → `cargo tauri build` (produces `.deb` + `.AppImage`)
|
||||
→ de-bundle the AppImage's graphics libs → verify the `.deb` → repackage for
|
||||
pacman.
|
||||
- **The three analyzer steps run from the REPO ROOT with `--workspace`**, not
|
||||
from `desktop/src-tauri`. Scoping them to the desktop package was correct while
|
||||
it was the only Rust here; after the core was extracted it silently stopped
|
||||
being — the core's 89 tests stopped running, and a fourth crate would not be
|
||||
linted at all. The dependency crates still COMPILE either way, which is exactly
|
||||
why the gap is invisible from a green run. If you add a workspace member, check
|
||||
that it appears in the `cargo test` output before believing the lane covers it.
|
||||
- **`APPIMAGE_EXTRACT_AND_RUN=1`** is set: AppImage tooling FUSE-mounts by default
|
||||
and CI containers have no `/dev/fuse`.
|
||||
- **Packaging tools used from the image** (none installed at job time, rule 5):
|
||||
`dpkg-deb` / `dpkg-query` / `apt-cache` and `dpkg-shlibdeps` (from `dpkg-dev`,
|
||||
pulled in by `build-essential`) for `desktop/packaging/deb/verify.sh`; `tar` +
|
||||
a compressor for `desktop/packaging/arch/package-prebuilt.sh`. Both scripts
|
||||
degrade gracefully rather than hard-failing on an absent optional tool:
|
||||
`bsdtar` (`libarchive-tools`) is used for the pacman package's `.MTREE` when
|
||||
present and skipped when not, compression falls back zstd → xz → gzip, and the
|
||||
`.deb` clean-container install test runs only if a docker CLI is available.
|
||||
Run 2872 confirmed all three optional tools are ABSENT today, so the current
|
||||
build takes every fallback: the pacman package ships as `.pkg.tar.xz` with no
|
||||
`.MTREE`, and the `.deb` clean-container install test is skipped. All three are
|
||||
functional outcomes — pacman installs an `.xz` package fine, and only
|
||||
`pacman -Qkk` file verification needs `.MTREE`. Adding `libarchive-tools` +
|
||||
`zstd` + a docker CLI to `ci-tauri` would upgrade these paths; none of them
|
||||
block a green build.
|
||||
- **`libssl-dev` + `pkg-config` are load-bearing** (both already in `ci-tauri`).
|
||||
Since M10.6 the desktop crate depends on `reqwest` with the **`native-tls`**
|
||||
backend, which on Linux compiles against OpenSSL. Do NOT drop either package
|
||||
from `ci-tauri` in a future slim-down — the Rust build fails at `openssl-sys`.
|
||||
(They're part of Tauri's own documented Linux prerequisites, so they should
|
||||
stay regardless.)
|
||||
- **`libssl3` is covered transitively, on purpose — don't "fix" it.** Since
|
||||
M10.6 `dpkg-shlibdeps` lists `libssl3` among the binary's needs, but the
|
||||
`.deb` declares only `libwebkit2gtk-4.1-0` + `libgtk-3-0`. `verify.sh` passes
|
||||
it because webkit's own recursive dependency closure includes OpenSSL, so apt
|
||||
installs it either way. Declaring it explicitly would be *worse*: the package
|
||||
name is release-dependent (`libssl3` on bookworm, `libssl3t64` after the
|
||||
64-bit-time_t transition in trixie/Ubuntu 24.04), so a hardcoded name freezes
|
||||
the package to the build distro. Leaning on webkit's closure adapts. If webkit
|
||||
ever stops pulling OpenSSL, `verify.sh` fails the build loudly — that guard is
|
||||
what makes the indirection safe.
|
||||
- **Not verifiable in CI:** the runner is Debian, so the pacman package cannot be
|
||||
`pacman -U`-tested here. That step logs `.PKGINFO` + the full file listing so
|
||||
the package is auditable from the run log; a real Arch install is the operator's
|
||||
confirm.
|
||||
|
||||
### Windows lane — second job, second image
|
||||
|
||||
`desktop.yml` also runs a `windows` job that cross-compiles the NSIS installer.
|
||||
|
||||
- **Image:** `git.fabledsword.com/bvandeusen/ci-tauri-win:1.97` (Rust + Node +
|
||||
`cargo-xwin` + LLVM/`lld` + NSIS). A separate image from `ci-tauri` per
|
||||
CI-Runner's `docs/process.md` fork rule — the MSVC CRT/SDK cache alone is >1 GB.
|
||||
Its pins are held in lockstep with `ci-tauri`; bump them together, since both
|
||||
lanes compile the same source.
|
||||
- **Why cross-compile:** there is no Windows build host, and a Windows container
|
||||
cannot run on a Linux host (containers share the host kernel). `cargo-xwin`,
|
||||
`lld-link` and `makensis` are Linux programs that emit Windows PE output.
|
||||
- **NSIS only.** `.msi` requires WiX v3, a Windows program — per Tauri, "`.msi`
|
||||
installers can only be created on Windows."
|
||||
- **Separate job on purpose:** a Windows failure must not block the Linux
|
||||
artifacts, which are the primary product today.
|
||||
- **Weakest verification of any lane.** Tauri documents this path as "not as
|
||||
straight forward as compiling on Windows directly and is not tested as much",
|
||||
to be used "only as a last resort" — and a Linux runner cannot execute a
|
||||
Windows binary. Green means it *built*. A real Windows machine check is
|
||||
mandatory before trusting a release.
|
||||
- **Unsigned.** Installers will trip SmartScreen until a code-signing
|
||||
certificate exists; that is a purchasing decision, not a CI one.
|
||||
- **TLS backend is chosen for this lane's sake.** The desktop crate pins
|
||||
`reqwest` to `native-tls`, which on `x86_64-pc-windows-msvc` resolves to
|
||||
`schannel` — pure-Rust bindings to the OS TLS stack. That keeps C/assembly out
|
||||
of the cross-compile entirely. Switching to `rustls` would pull in
|
||||
`ring`/`aws-lc-rs` and their assembler, which is exactly the class of
|
||||
dependency that broke this lane before (`libsqlite3-sys` → `llvm-lib`). Treat
|
||||
a TLS-backend change as a change to *this lane*, not just a dependency bump.
|
||||
- No Postgres lane (unchanged): the desktop app's local store + sync behavior is
|
||||
verified on the operator's machine, not in CI.
|
||||
|
||||
## Android lane — being rebuilt (M12)
|
||||
|
||||
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
|
||||
over the shared `inkwell-core` crate instead — see Scribe note 2730 for the
|
||||
decision and milestone M12 for the arc.
|
||||
|
||||
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
|
||||
from `ci-tauri-android` rather than deleted (CI-runner `dc802f2`, Scribe #2732).
|
||||
`tauri-cli` is out and `cargo-ndk` is in; the NDK binutils symlinks and the PATH
|
||||
append stayed, because they were never Tauri problems — NDK r23 removed the
|
||||
triple-prefixed binutils that autotools, and so vendored OpenSSL, invokes by bare
|
||||
name. It also carries `ktlint` + `detekt` so the Kotlin analyzer lane needs no
|
||||
second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper —
|
||||
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
|
||||
|
||||
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
|
||||
`inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
|
||||
mismatched Rust minor across the lanes would mean divergent resolution for no
|
||||
reason. Bump the three together or not at all.
|
||||
|
||||
## Checking the Kotlin lane before pushing
|
||||
|
||||
Same authorisation and same reasoning as the Rust section below — analyzers, run
|
||||
in the CI image, with the workflow's exact arguments. From `android/`:
|
||||
|
||||
```
|
||||
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
|
||||
|
||||
$DOCK $IMG ktlint "app/src/main/**/*.kt"
|
||||
$DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
|
||||
--input app/src/main/java
|
||||
```
|
||||
|
||||
`HOME=/tmp` because both tools want a writable home for their caches and
|
||||
`--user` has taken the image's away.
|
||||
|
||||
**Neither of these can see a missing import.** They parse Kotlin without
|
||||
resolving symbols, so a file that cannot possibly compile passes both. That is
|
||||
not a gap to work around — it is what these tools are — but it means a clean
|
||||
local run says nothing about whether the code builds. It cost a red CI run on
|
||||
`750d11d`, where `android.os.Build` was lost in a file split and both analyzers
|
||||
were happy.
|
||||
|
||||
So there are two more local checks, each covering one blind spot:
|
||||
|
||||
```
|
||||
python3 android/tools/check-symbols.py
|
||||
python3 android/tools/check-strings.py
|
||||
```
|
||||
|
||||
`check-symbols.py` flags any capitalised identifier that is neither imported,
|
||||
declared in the same package, a type parameter, nor implicitly available — and
|
||||
members of this package's own `object` declarations, so that `Foo.bar()` fails
|
||||
here when `Foo` has no `bar`. That second case exists because moving a function
|
||||
between two objects and forgetting to paste it into the second cost a red run
|
||||
(785ebdb): the call site was correctly qualified and every other gate passed.
|
||||
Not a type checker — `compileDebugKotlin` in CI remains the only real one, and it is
|
||||
also the ONLY lane that type-checks at all, since there is no Android SDK on the
|
||||
workstation.
|
||||
|
||||
`check-strings.py` covers resources, where the compiler is no help either: `R`
|
||||
is generated, so `R.string.whatever` type-checks whether or not the string
|
||||
exists. It catches a missing name, `stringResource` used on a plural or the
|
||||
reverse, and a format string that takes more arguments than the call passes —
|
||||
the last of which renders `%2$s` as literal text rather than failing.
|
||||
|
||||
Run all four before a push that touches Kotlin.
|
||||
|
||||
A caution worth keeping, because it bit twice: a checker of this shape is itself
|
||||
easy to get vacuously right. The first version stripped line comments with
|
||||
`re.sub(r'//.*', src, flags=re.S)`, and DOTALL makes `//.*` swallow each file
|
||||
from its first comment to EOF — so it reported everything clean by examining
|
||||
almost nothing. **Test a checker against a known-bad tree before trusting a
|
||||
green from it**. `check-symbols.py` is verified by deleting the `Build` import
|
||||
from a copy of the source; `check-strings.py` by introducing one of each of its
|
||||
three fault kinds. Its own first version counted Kotlin's trailing commas as
|
||||
arguments and reported three correct call sites as broken — the opposite failure,
|
||||
and the one that teaches you to ignore the tool.
|
||||
|
||||
## A fourth Kotlin check: read the artifact, don't recall the API
|
||||
|
||||
Compose comes from a BOM (`compose-bom` in `libs.versions.toml`), so no file in
|
||||
this repo states which `material3` a build actually gets. Guessing its API and
|
||||
finding out from CI costs eight minutes a try. Resolve and read it instead:
|
||||
|
||||
```
|
||||
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
|
||||
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
|
||||
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
|
||||
|
||||
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
|
||||
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
|
||||
```
|
||||
|
||||
The sources jar answers what javap cannot: default arguments, parameter names,
|
||||
and whether a declaration carries `@ExperimentalMaterial3Api`. That last one is
|
||||
not optional trivia — an unnecessary `@OptIn` is itself a Kotlin warning, so
|
||||
guessing "safely" breaks the build's zero-warning record just as surely as
|
||||
omitting a required one breaks the build.
|
||||
|
||||
Same technique for any dependency. It is how `work-runtime-ktx` was found to be
|
||||
an empty 6 KB stub as of 2.11, with `CoroutineWorker` and
|
||||
`PeriodicWorkRequestBuilder` moved into `work-runtime` itself.
|
||||
|
||||
## Checking the Rust lane before pushing
|
||||
|
||||
There is no Rust toolchain on the workstation (rule 10) and the desktop lane is
|
||||
verified entirely in CI — but the CI image is pullable, so the three analyzer
|
||||
steps can be run against it locally first. **The operator authorised this on
|
||||
2026-08-18** for `fmt`, `clippy` and `test`; it is not licence to run the bundle
|
||||
build or stand up anything.
|
||||
### Rust
|
||||
|
||||
Run all three, in this order, before any push that touches Rust:
|
||||
|
||||
@@ -326,70 +182,109 @@ $DOCK $IMG cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
$DOCK $IMG cargo test --locked --workspace
|
||||
```
|
||||
|
||||
Drop `--check` from the first to apply it. `--user` keeps the container from
|
||||
leaving root-owned files behind; `CARGO_HOME` points somewhere writable for that
|
||||
user. Commands are IDENTICAL to the workflow's, deliberately — a local check that
|
||||
differs from CI is worse than none.
|
||||
Drop `--check` from the first to apply it. `--user` keeps the container from leaving
|
||||
root-owned files behind; `CARGO_HOME` points somewhere writable for that user.
|
||||
`target/` persists between runs, so after the first cold build these take seconds; it
|
||||
is gitignored and reaches ~1.4 GB.
|
||||
|
||||
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
|
||||
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
|
||||
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`,
|
||||
named for the crates as they were before the rename to Inkwell) matched CI run
|
||||
3931's byte for byte. Same image, same lockfile, same units.
|
||||
Run them on every Rust-touching push, not just the risky-looking ones. A removal looks
|
||||
safe and often isn't — a private `fn` deleted along with the `pub fn` above it, an
|
||||
orphaned `#[serde]` attribute — and nothing in the Python or TypeScript lanes compiles
|
||||
Rust, so a break can travel several commits before a lane that does gets to it.
|
||||
|
||||
`target/` persists on the host between runs, so after the first cold build these
|
||||
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
||||
whenever the space is wanted.
|
||||
**Don't infer formatting from existing code.** Some lines in `local/store.rs` exceed
|
||||
100 characters and survive only because rustfmt cannot break a string literal.
|
||||
|
||||
**Run these on every Rust-touching push, not just the ones that feel risky.** Four
|
||||
consecutive failures across M13's removals — a private `fn` deleted along with the
|
||||
`pub fn` above it, an orphaned `#[serde]` attribute left where a field was removed,
|
||||
and a test pinning a protocol version literal — were all caught by these three
|
||||
commands in under a minute each, after CI had already found them the slow way. A
|
||||
removal is exactly the kind of change that looks safe and isn't: nothing in the
|
||||
Python or TypeScript lanes compiles Rust, so a break can travel several commits
|
||||
before the first lane that does gets to it.
|
||||
### Kotlin
|
||||
|
||||
**Don't infer formatting from existing code.** Several lines in `local/store.rs`
|
||||
exceed 100 characters and survive only because rustfmt cannot break a string
|
||||
literal — copying that shape caused one of four consecutive fmt-only CI failures,
|
||||
which is what this whole section exists to prevent.
|
||||
From `android/`:
|
||||
|
||||
## Checking the frontend lane before pushing
|
||||
```
|
||||
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
|
||||
|
||||
Same technique, same authorisation, same reason — and it covers a gap the Rust gate
|
||||
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE
|
||||
passes the typecheck lane and fails `vite build` in a different workflow. `npm run
|
||||
build` runs both, which is exactly what the desktop lanes run.
|
||||
$DOCK $IMG ktlint "app/src/main/**/*.kt"
|
||||
$DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
|
||||
--input app/src/main/java
|
||||
```
|
||||
|
||||
`HOME=/tmp` because both tools want a writable home for their caches and `--user` has
|
||||
taken the image's away.
|
||||
|
||||
**Neither can see a missing import.** They parse Kotlin without resolving symbols, so
|
||||
a file that cannot compile passes both. Two more checks cover the blind spots:
|
||||
|
||||
```
|
||||
python3 android/tools/check-symbols.py
|
||||
python3 android/tools/check-strings.py
|
||||
```
|
||||
|
||||
`check-symbols.py` flags any capitalised identifier that is neither imported, declared
|
||||
in the same package, a type parameter, nor implicitly available — and members of this
|
||||
package's own `object` declarations, so `Foo.bar()` fails when `Foo` has no `bar`. It
|
||||
is not a type checker: `compileDebugKotlin` in CI is the only one.
|
||||
|
||||
`check-strings.py` covers resources, where the compiler is no help: `R` is generated,
|
||||
so `R.string.whatever` type-checks whether or not the string exists. It catches a
|
||||
missing name, `stringResource` used on a plural or the reverse, and a format string
|
||||
that takes more arguments than the call passes — which renders `%2$s` as literal text
|
||||
rather than failing.
|
||||
|
||||
Run all four before a push that touches Kotlin.
|
||||
|
||||
A checker of this shape is easy to get vacuously right: `re.sub(r'//.*', ...,
|
||||
flags=re.S)` makes `//.*` swallow each file from its first comment to EOF and report
|
||||
everything clean. **Test a checker against a known-bad tree before trusting a green
|
||||
from it.** `check-symbols.py` is verified by deleting an import from a copy of the
|
||||
source; `check-strings.py` by introducing one of each of its three fault kinds.
|
||||
|
||||
**Read the artifact, don't recall the API.** Compose comes from a BOM (`compose-bom`
|
||||
in `libs.versions.toml`), so no file here states which `material3` a build gets, and
|
||||
guessing costs eight minutes of CI a try. Resolve and read it:
|
||||
|
||||
```
|
||||
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
|
||||
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
|
||||
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
|
||||
|
||||
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
|
||||
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
|
||||
```
|
||||
|
||||
The sources jar answers what javap cannot: default arguments, parameter names, and
|
||||
whether a declaration carries `@ExperimentalMaterial3Api`. An unnecessary `@OptIn` is
|
||||
itself a Kotlin warning, so guessing "safely" breaks the zero-warning build as surely
|
||||
as omitting a required one. The same works for any dependency — it is how
|
||||
`work-runtime-ktx` shows up as an empty 6 KB stub as of 2.11, with `CoroutineWorker`
|
||||
and `PeriodicWorkRequestBuilder` in `work-runtime` itself.
|
||||
|
||||
### Frontend
|
||||
|
||||
`vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE passes
|
||||
the typecheck lane and fails `vite build` in the desktop lanes. `npm run build` runs
|
||||
both:
|
||||
|
||||
```
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
|
||||
```
|
||||
|
||||
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs
|
||||
already run on, not a separate one. Delete `frontend/node_modules` and `frontend/dist`
|
||||
afterwards; both are gitignored, but neither belongs in a working tree that never
|
||||
builds locally otherwise.
|
||||
Delete `frontend/node_modules` and `frontend/dist` afterwards.
|
||||
|
||||
## The desktop lockfile
|
||||
## The Cargo lockfile
|
||||
|
||||
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
|
||||
binary crates. Without it every CI run re-resolved the graph, which meant a
|
||||
released `.deb`/`.AppImage`/`.exe` couldn't be rebuilt from its tag, a build
|
||||
could break with no repo change, and Renovate had nothing to bump (issue 2102).
|
||||
`Cargo.lock` is **committed** at the workspace root, per Cargo's guidance for binary
|
||||
crates, so a build is reproducible from its commit and can't break with no repo
|
||||
change, and Renovate has something to bump.
|
||||
|
||||
Enforced by `--locked` on each job's **first** cargo invocation — `cargo clippy
|
||||
--locked` on Linux, a dedicated `cargo fetch --locked --target
|
||||
x86_64-pc-windows-msvc` step on Windows. If the manifest and the lockfile
|
||||
disagree, the run fails there instead of silently re-resolving; everything after
|
||||
it in the same job then compiles the recorded versions, so the flag isn't
|
||||
repeated on the bundle build. The Windows step exists separately because that
|
||||
job's only crate-graph command is the cross-compile itself, and drift is cheaper
|
||||
to learn in the first thirty seconds than thirty minutes in.
|
||||
`--locked` on each job's **first** cargo invocation enforces it — `cargo clippy
|
||||
--locked` on Linux, a dedicated `cargo fetch --locked --target x86_64-pc-windows-msvc`
|
||||
step on Windows. If the manifest and the lockfile disagree, the run fails there;
|
||||
everything after it in the same job compiles the recorded versions. The Windows step
|
||||
exists because that job's only other crate-graph command is the cross-compile, and
|
||||
drift is cheaper to learn in thirty seconds than thirty minutes in.
|
||||
|
||||
To regenerate it after a dependency change — same reasoning as `cargo fmt`
|
||||
above, and resolution is neither a test run nor a build:
|
||||
To regenerate it after a dependency change:
|
||||
|
||||
```
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
||||
@@ -397,59 +292,48 @@ docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
||||
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch
|
||||
```
|
||||
|
||||
**`cargo fetch`, not `cargo generate-lockfile`.** Both update the lockfile, but
|
||||
generate-lockfile re-resolves the whole graph from scratch and will happily bump
|
||||
crates that have nothing to do with your change — turning a two-line manifest
|
||||
edit into a few-hundred-line lockfile diff nobody can review. `cargo fetch`
|
||||
performs the minimal resolution: existing pins are preserved, only the new
|
||||
entries are added. Verify it stayed additive before committing (`git diff
|
||||
Cargo.lock | grep '^-'` should show nothing but re-ordered dependency lists).
|
||||
|
||||
Resolving inside the CI image rather than against some other cargo is what keeps
|
||||
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.
|
||||
**`cargo fetch`, not `cargo generate-lockfile`.** generate-lockfile re-resolves the
|
||||
whole graph and will bump crates unrelated to your change, turning a two-line manifest
|
||||
edit into a few-hundred-line lockfile diff. `cargo fetch` keeps existing pins and adds
|
||||
only new entries. Check it stayed additive (`git diff Cargo.lock | grep '^-'` should
|
||||
show nothing but re-ordered dependency lists), and commit it with the `Cargo.toml`
|
||||
edit — a manifest change pushed without it fails the gate.
|
||||
|
||||
## Channel releases: `dev-rolling` and `stable`
|
||||
|
||||
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.
|
||||
`/releases/latest/download/<asset>` route and the updater needs a permanent URL. The
|
||||
**channel** is called `dev` everywhere a person sees it; its **release tag** is
|
||||
`dev-rolling`, because a tag named `dev` would shadow the branch and make `git push
|
||||
origin dev` fail with `error: src refspec dev matches more than one`.
|
||||
`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. `tests/test_channel_tag.py`
|
||||
and the `update.rs` tests fail if a channel tag is spelled like a branch.
|
||||
|
||||
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 old `dev` release and tag were deleted on 2026-10-08 (Scribe #3884), with the
|
||||
bridge that kept writing to them. A desktop app installed before 2026-09-10 reads
|
||||
that tag and gets no further updates; reinstall it with `install.sh`.
|
||||
A desktop app installed before 2026-09-10 reads a `dev` tag that no longer exists and
|
||||
gets no further updates; reinstall it with `install.sh`.
|
||||
|
||||
## Shell scripts have no CI lane
|
||||
|
||||
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script
|
||||
fails at the moment a user runs it, not in a build. Check them before pushing —
|
||||
`install.sh` is POSIX sh, the rest are bash:
|
||||
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script fails
|
||||
when a user runs it, not in a build. Check them before pushing — `install.sh` is POSIX
|
||||
sh, the rest are bash:
|
||||
|
||||
```
|
||||
dash -n desktop/packaging/install.sh # or: sh -n
|
||||
bash -n desktop/packaging/publish-release.sh
|
||||
```
|
||||
|
||||
Where a script resolves URLs from the Fabled-Git API, exercise the resolution
|
||||
against the live instance (plain `curl` reads, no install) rather than trusting
|
||||
the regex by eye. Both channel paths in `install.sh` were verified that way.
|
||||
Where a script resolves URLs from the Fabled-Git API, exercise the resolution against
|
||||
the live instance (plain reads, no install) rather than trusting the regex by eye.
|
||||
|
||||
**Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds
|
||||
its request bodies as shell strings, and quoting context decides what survives
|
||||
into the JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the
|
||||
shell, the same `` \ `` inside a single-quoted variable does not, and reaches
|
||||
Fabled-Git as an illegal escape (HTTP 422, one wasted build). `sh -n` cannot see
|
||||
this. Extract the body block and parse it for every branch it can take:
|
||||
**Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds its
|
||||
request bodies as shell strings, and quoting context decides what survives into the
|
||||
JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the shell, the same
|
||||
`` \ `` inside a single-quoted variable does not, and reaches Fabled-Git as an illegal
|
||||
escape (HTTP 422). `sh -n` cannot see this. Extract the body block and parse it for
|
||||
every branch it can take:
|
||||
|
||||
```
|
||||
sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh
|
||||
|
||||
@@ -22,9 +22,7 @@ copy.
|
||||
|
||||
**The channel is a property of the image you run.** A `:dev` image bakes in the
|
||||
dev-channel APK, `:latest` the stable one — so pointing a phone at a stable
|
||||
server gets it a stable client, with no second place holding that decision. (Until
|
||||
M314 step 3 the fetch was hard-wired to the dev release on every branch, so a
|
||||
stable server served a dev client.)
|
||||
server gets it a stable client, with no second place holding that decision.
|
||||
|
||||
An image therefore carries the *newest* client on its channel rather than one
|
||||
pinned to a version. That is deliberate: the two negotiate a sync protocol version
|
||||
@@ -106,5 +104,5 @@ shipped with, which is the whole reason precedence runs in that direction.
|
||||
Every release build is signed with the same key, so a phone can install a newer one
|
||||
straight over the old one and keep its notes. That was not true before August 2026
|
||||
— builds until then were signed with a throwaway key per CI run, and each install
|
||||
required uninstalling the last (Scribe #2803). If you are carrying a build from
|
||||
required uninstalling the last. If you are carrying a build from
|
||||
before that, expect to uninstall once more and sync anything you care about first.
|
||||
|
||||
+9
-9
@@ -52,22 +52,22 @@ syncs everything else.
|
||||
### The policy
|
||||
|
||||
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
|
||||
- v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1
|
||||
- v2: `kind` and `title` left the wire; **floor raised**, because a v1
|
||||
client kept pushing both and read back notes carrying neither — and `title` was
|
||||
the note's NAME, so an old client showed nameless notes.
|
||||
- v3: attachments/tombstones/revisions.
|
||||
- v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade
|
||||
- v4: `color` left the note; **floor NOT raised**. Both directions degrade
|
||||
in silence and neither loses anything visible — an old client reading a v4 note
|
||||
falls back to the colour it derives locally, and one pushing `color` has the key
|
||||
ignored. The test is not "did a field leave" but "does either side end up
|
||||
showing something wrong".
|
||||
- v5 (#5168): attachments became a sync entity — an upload route keyed by the
|
||||
- v5: attachments became a sync entity — an upload route keyed by the
|
||||
client's id, and `attachment`/`preview` deletes in push. Additive, so it is
|
||||
the `attachment_sync` feature and the floor stays.
|
||||
- v6 (#5175): shared notes — see "Shared notes" below. Opt-in per request
|
||||
- v6: shared notes — see "Shared notes" below. Opt-in per request
|
||||
(`changes?shares=1`), so it is the `shares` feature and the floor stays: a v5
|
||||
client never asks and gets exactly its own notes.
|
||||
- v7 (#5176): a recipient's own pin, archive and position on a shared note —
|
||||
- v7: a recipient's own pin, archive and position on a shared note —
|
||||
see "Shared notes". Additive, so it is the `shared_state` feature and the
|
||||
floor stays: a v6 client pushes only text, and reads its own state as if the
|
||||
note were simply unpinned.
|
||||
@@ -143,7 +143,6 @@ as `?since=`. `since=0` (or absent) is a **full initial sync**.
|
||||
rename/recolor/delete propagates independently of notes.
|
||||
- **Derived, NOT synced:** `#tags` are parsed from the note body. Clients recompute
|
||||
them locally; the server recomputes them on push. They never travel over the wire.
|
||||
(`[[wiki-links]]` were derived the same way until they were removed — note 2897.)
|
||||
- **Attachment blobs** sync by id over the existing upload/download routes (see
|
||||
Attachments below); only their metadata rides the delta feed.
|
||||
|
||||
@@ -222,7 +221,7 @@ labels are personal, and a recipient files nothing under the owner's tags.
|
||||
Granting or changing a share moves the note to a new revision (without touching
|
||||
`updated_at`, so last-write-wins is unaffected), which is how it passes a
|
||||
recipient's cursor. Joining a group a note is shared with does the same for the
|
||||
new member (#5177). Ending a share — or the owner purging the note, the person
|
||||
new member. Ending a share — or the owner purging the note, the person
|
||||
leaving the group, or the admin deleting it — adds the note to the `revoked` list of
|
||||
each person who can no longer see it:
|
||||
|
||||
@@ -261,9 +260,10 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
|
||||
state (not a partial patch) — the server overwrites all scalar fields and sets
|
||||
manual label memberships from `label_ids` (tag-sourced labels are re-derived
|
||||
from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines
|
||||
inside `body` (M304), so there is no separate `items` array.
|
||||
inside `body`, so there is no separate `items` array.
|
||||
- **Fields a change may still carry, and the server reads past.** `title` and
|
||||
`kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads
|
||||
`kind` (removed in v2), `items` (from before checklists moved into `body`) and
|
||||
`color` (removed in v4). The server reads
|
||||
its payload key by key and never validates the shape, which is exactly what lets
|
||||
an older client keep pushing a field this one has stopped storing — see the
|
||||
version policy above for why none of those needed a floor raise on their own.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Inkwell">
|
||||
<!-- Rounded-tile app icon: brand yellow, and the inkwell — a black inkpot with a
|
||||
quill standing in it. Source for every raster icon; see step 5 of milestone 481. -->
|
||||
quill standing in it. Source for every raster icon. -->
|
||||
<rect width="512" height="512" rx="112" fill="#F5C518"/>
|
||||
<g transform="translate(256 256) scale(0.92) translate(-262 -226)">
|
||||
<defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -8,11 +8,10 @@
|
||||
# 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.
|
||||
# A tag named `dev` would shadow the branch named `dev`: once a clone had fetched
|
||||
# tags, `git push origin dev` would fail with "src refspec dev matches more than
|
||||
# one". So the dev channel's tag is `dev-rolling`. 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
|
||||
|
||||
@@ -39,14 +39,14 @@ esac
|
||||
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
|
||||
REPO="${GITHUB_REPOSITORY:-bvandeusen/inkwell}"
|
||||
# The channel's release tag, not its name — `dev` lives on `dev-rolling`
|
||||
# (packaging/channel-tag.sh, Scribe #2184).
|
||||
# (packaging/channel-tag.sh).
|
||||
tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")"
|
||||
BASE="$SERVER/$REPO/releases/download/$tag"
|
||||
|
||||
mkdir -p "$dest"
|
||||
|
||||
# Authenticated when we have a token — these releases are private (issue 2091), so
|
||||
# on this instance we always do. Anonymous still works against a public fork.
|
||||
# Authenticated when we have a token, which a private repo's releases need.
|
||||
# Anonymous still works against a public fork.
|
||||
fetch() {
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1"
|
||||
|
||||
+20
-22
@@ -6,18 +6,17 @@
|
||||
# guard-forward.sh compare <a> <b> exit 0 iff a sorts below b
|
||||
# guard-forward.sh published <artifact> <channel> print what the channel serves
|
||||
#
|
||||
# Note 3127 §6.3. Everything else in this milestone derives a number and trusts it;
|
||||
# this is the one thing that checks the answer against reality before a user gets it.
|
||||
# Everything else in the versioning derives a number and trusts it; this is the one
|
||||
# thing that checks the answer against reality before a user gets it.
|
||||
#
|
||||
# WHAT IT CATCHES that nothing else does:
|
||||
#
|
||||
# * A SQUASH OR REBASE MERGE (§6.2). Both rewrite the committer date, so `main`
|
||||
# could stamp a value unrelated to the dev commit it merged. Rule 153 mandates
|
||||
# plain merge commits — but that rule governs people, and a forge UI's squash
|
||||
# button does not read it.
|
||||
# * A SQUASH OR REBASE MERGE. Both rewrite the committer date, so `main` could
|
||||
# stamp a value unrelated to the dev commit it merged. This repo merges with
|
||||
# plain merge commits, but a forge UI's squash button is still one click away.
|
||||
# * A REBUILD OF AN OLDER COMMIT. Commit time can go backwards; this is the entire
|
||||
# mitigation for the desktop key's clock choice (step 4), and the thing to
|
||||
# revisit first if this repo ever starts rebuilding old commits routinely.
|
||||
# mitigation for the desktop key's clock choice, and the thing to revisit first
|
||||
# if this repo ever starts rebuilding old commits routinely.
|
||||
# * CLOCK SKEW between runners, for a build-time key.
|
||||
#
|
||||
# What it does NOT catch, because something better does: a shallow clone. That is
|
||||
@@ -26,8 +25,8 @@
|
||||
#
|
||||
# TOO-LOW IS THE UNRECOVERABLE DIRECTION. A version below what is published means
|
||||
# every installed client reports "up to date" forever and there is no build you can
|
||||
# ship to fix it — you have to get back ABOVE the bad number. That is #2183 and
|
||||
# #2993's shared symptom, and it is why this fails the lane rather than warning.
|
||||
# ship to fix it — you have to get back ABOVE the bad number. That is why this fails
|
||||
# the lane rather than warning.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -54,8 +53,8 @@ version_lt() {
|
||||
return 1 # equal
|
||||
}
|
||||
|
||||
# Auth if we have it, anonymous if not — the releases are public, but a token costs
|
||||
# nothing and keeps this working if that ever changes.
|
||||
# Auth if we have it, anonymous if not — a token costs nothing, and a private repo's
|
||||
# releases need one.
|
||||
#
|
||||
# MISSING CURL IS FATAL, not empty. Every fetch here ends in `|| true` so a network
|
||||
# blip reads as "nothing published yet" and passes — which is right for a genuinely
|
||||
@@ -78,7 +77,7 @@ fetch() {
|
||||
|
||||
# What the channel is serving, per artifact. ONE definition of where to look, shared
|
||||
# with `should-build.sh` — the skip decision and the guard must agree about what is
|
||||
# published, and two readers of one fact is how this repo keeps producing #2181-2183.
|
||||
# published, and two readers of one fact drift apart.
|
||||
#
|
||||
# EVERY LOOKUP HERE MUST SUCCEED EVEN WHEN IT FINDS NOTHING. That is what the `|| true`
|
||||
# on each pipeline is for, and it is load-bearing rather than defensive noise.
|
||||
@@ -89,15 +88,14 @@ fetch() {
|
||||
# exits non-zero kills the script before that branch is ever reached. Silently, too:
|
||||
# everything the pipeline would have said went into the capture rather than the log.
|
||||
#
|
||||
# WHICH COMMAND THE PIPELINE HAPPENS TO END ON decides whether that fires, which is the
|
||||
# part worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these
|
||||
# four lookups end in `sed` and were fine. The one that ends in `grep -oE '[0-9]+$'` —
|
||||
# Android's version_code — was not, and it failed the whole Android lane on the first
|
||||
# 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.
|
||||
# WHICH COMMAND THE PIPELINE ENDS ON decides whether that fires, which is the part
|
||||
# worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these four
|
||||
# lookups end in `sed`; the one that ends in `grep -oE '[0-9]+$'` — Android's
|
||||
# version_code — would fail the lane, silently, on any channel with no APK published
|
||||
# yet.
|
||||
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
|
||||
# (packaging/channel-tag.sh). `|| 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
|
||||
@@ -173,7 +171,7 @@ case "$artifact" in
|
||||
published="$(published_for desktop "$channel")"
|
||||
# COMMIT time, so EQUALITY IS THE ORDINARY CASE: an unchanged source derives
|
||||
# exactly what it derived last time, and `<=` would fail every no-change build.
|
||||
# §6.3 says *strictly* less for exactly this reason.
|
||||
# So this fails only on *strictly* less.
|
||||
strict=""
|
||||
;;
|
||||
android)
|
||||
@@ -212,7 +210,7 @@ if version_lt "$derived" "$published"; then
|
||||
echo " forever, and no later build fixes that until one climbs back above the" >&2
|
||||
echo " bad number. Do not force past this." >&2
|
||||
echo "" >&2
|
||||
echo " Usual causes (note 3127 §6.2, §6.3):" >&2
|
||||
echo " Usual causes:" >&2
|
||||
echo " - a squash or rebase merge rewrote the committer date" >&2
|
||||
echo " - this build is a rebuild of an older commit" >&2
|
||||
echo " - clock skew between runners (build-time keys)" >&2
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
"""Every icon Inkwell ships, from one drawing.
|
||||
|
||||
The mark is a black inkpot with a quill standing in it, on the brand yellow
|
||||
(Scribe note 5071). It is drawn ONCE below, in a 512 box, and each variant places it
|
||||
The mark is a black inkpot with a quill standing in it, on the brand yellow.
|
||||
It is drawn ONCE below, in a 512 box, and each variant places it
|
||||
with its own scale, because each platform crops differently:
|
||||
|
||||
- icon.svg the rounded tile: web favicon/PWA "any", desktop app icon
|
||||
@@ -70,7 +70,7 @@ def svg(body, label=True):
|
||||
|
||||
def tile(scale=0.92):
|
||||
return svg(f''' <!-- Rounded-tile app icon: brand yellow, and the inkwell — a black inkpot with a
|
||||
quill standing in it. Source for every raster icon; see step 5 of milestone 481. -->
|
||||
quill standing in it. Source for every raster icon. -->
|
||||
<rect width="512" height="512" rx="112" fill="{GOLD}"/>
|
||||
<g transform="{place(scale)}">{GLYPH}
|
||||
</g>''')
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
#
|
||||
# release-notes.sh <tag>
|
||||
#
|
||||
# A release BUILDS NOTHING now (M314 step 7). The merge to `main` already published
|
||||
# `:latest`, `:<sha>` and both channel feeds, so a tag rebuilding that same source
|
||||
# would produce identical artifacts and re-push `:<sha>` with different bytes —
|
||||
# which rule 145 forbids even when the bytes match.
|
||||
# A release BUILDS NOTHING. The merge to `main` already published `:latest`,
|
||||
# `:<sha>` and both channel feeds, and a tag rebuilding that same source would
|
||||
# re-push `:<sha>`, which a published tag never allows.
|
||||
#
|
||||
# So what is a release FOR? Note 3127 §5 answers it: the changelog. There are two
|
||||
# What a release is FOR is 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
|
||||
|
||||
+11
-19
@@ -5,27 +5,21 @@
|
||||
#
|
||||
# should-build.sh <desktop|android> <dev|stable>
|
||||
#
|
||||
# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by
|
||||
# VERSION and rule 145 removed exactly that. There is no `:<version>` tag to ask
|
||||
# about. What there IS, for both clients, is a channel that publishes the version it
|
||||
# is serving, and that answers the same question: if the channel already serves what
|
||||
# this source derives, the artifact would be byte-identical and there is nothing to
|
||||
# build.
|
||||
# Skip-if-exists, keyed on what the channel serves rather than on a registry tag:
|
||||
# there are no `:<version>` tags to ask about. Both clients' channels publish the
|
||||
# version they serve, so if the channel already serves what this source derives,
|
||||
# the artifact would be byte-identical and there is nothing to build.
|
||||
#
|
||||
# WHAT THIS REPLACES, and why that matters more here than the cost saving: the
|
||||
# `paths:` filters in the workflows were a SECOND, independent statement of each
|
||||
# artifact's file set, hand-kept beside the one in `version.sh`. They disagreed
|
||||
# within a day of the sets being written — `packaging/` was added to the sets and
|
||||
# not to the filters, so the commit that fixed a derivation bug never ran on the two
|
||||
# lanes it fixed (85ead4d). §3 warns about exactly this duplication; one definition
|
||||
# with one reader is the fix, and the cost saving is a bonus.
|
||||
# This is also why the workflows carry no `paths:` filters: a second, hand-kept
|
||||
# statement of each artifact's file set beside the one in `version.sh` drifts from
|
||||
# it, and a lane whose filter misses a file never runs on the commit that changed
|
||||
# it. One definition with one reader; the cost saving is a bonus.
|
||||
#
|
||||
# THE SERVER IS NOT LISTED HERE, DELIBERATELY. Its image build is ~15 seconds against
|
||||
# 6 and 9 minutes for the clients, so there is little to save — and always building
|
||||
# it is strictly better for a server that can face the internet, because it picks up
|
||||
# `python:3.12-slim` base updates on every push. That is also why the base-image
|
||||
# tension in §4 does not bite this project: the artifact most exposed to it never
|
||||
# skips. The clients' bases are CI runner images, pinned deliberately.
|
||||
# `python:3.12-slim` base updates on every push. The clients' bases are CI runner
|
||||
# images, pinned deliberately.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -64,9 +58,7 @@ if [ "$derived" = "$published" ]; then
|
||||
# produce the same artifact under the same name and republish it for nothing.
|
||||
#
|
||||
# Skipping is safe here in a way it would not be if anything pinned: there is no
|
||||
# immutable tag to re-push with different bytes (rule 145 removed version tags),
|
||||
# so the immutability argument in §4.2 does not apply and this stands on cost
|
||||
# alone — which is the smaller, honest claim.
|
||||
# version tag to re-push with different bytes, so this stands on cost alone.
|
||||
echo "should-build: $channel already serves $artifact $derived — skipping." >&2
|
||||
echo false
|
||||
exit 0
|
||||
|
||||
+60
-84
@@ -2,15 +2,12 @@
|
||||
#
|
||||
# What version an artifact carries, derived from its OWN shipped files.
|
||||
#
|
||||
# Replaces desktop/packaging/build-version.sh, which was one generator feeding the
|
||||
# desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`. A Kotlin-only commit
|
||||
# re-versioned the desktop; a Rust-only commit re-versioned the phone. It read as
|
||||
# tidy — one definition, no drift — which is exactly why it survived review. One
|
||||
# definition of HOW to derive is right; one VALUE for unrelated artifacts is not.
|
||||
# (Note 3127 §3, which cites this repo as its example of the failure.)
|
||||
# One definition of HOW to derive, applied per artifact. One VALUE for unrelated
|
||||
# artifacts would re-version the desktop on a Kotlin-only commit and the phone on a
|
||||
# Rust-only one.
|
||||
#
|
||||
# Lives at the repo root, not under desktop/, because it now serves three artifacts
|
||||
# and a shared thing filed under one consumer is how it ends up owned by that one.
|
||||
# Lives at the repo root, not under desktop/, because it serves three artifacts and
|
||||
# a shared thing filed under one consumer ends up owned by that one.
|
||||
#
|
||||
# version.sh display <artifact> the human-readable version — 2026.08.28.1815
|
||||
# version.sh key <artifact> the ordering key a comparator reads
|
||||
@@ -20,7 +17,7 @@
|
||||
#
|
||||
# "is this the same code?" -> display. A dev build and the main build of one
|
||||
# commit read identically, because they ARE the
|
||||
# same bytes (note 3127 §2, reason 4).
|
||||
# same bytes.
|
||||
# "may this replace that?" -> key. What an updater or an install gate
|
||||
# compares, and never shown to a person.
|
||||
#
|
||||
@@ -29,14 +26,12 @@
|
||||
# the spec allows three, and `08` is a leading zero, which it forbids outright. A
|
||||
# non-semver string does not sort low: the feed fails to DESERIALIZE and every client
|
||||
# reports "no update available" forever. So the platform's field takes an opaque key
|
||||
# and the display version lives beside it. See #3142's spike.
|
||||
# and the display version lives beside it.
|
||||
#
|
||||
# WHY NOT A `-dev.N` PRERELEASE for the dev channel — carried over from the script
|
||||
# this replaces, because it is a real finding and the reasoning is not obvious:
|
||||
# a prerelease sorts BELOW the release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a
|
||||
# dev build could never be offered as an update to a tagged one, and Windows
|
||||
# installer metadata wants a numeric X.Y.Z anyway. The channel goes in a sibling
|
||||
# field, never in the version — note 3127 §7, and rule 149.
|
||||
# WHY NOT A `-dev.N` PRERELEASE for the dev channel: a prerelease sorts BELOW the
|
||||
# release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a dev build could never be
|
||||
# offered as an update to a tagged one, and Windows installer metadata wants a
|
||||
# numeric X.Y.Z anyway. The channel goes in a sibling field, never in the version.
|
||||
set -eu
|
||||
|
||||
# ANCHOR AT THE REPO ROOT BEFORE ANYTHING ELSE.
|
||||
@@ -46,47 +41,37 @@ set -eu
|
||||
# `desktop/src-tauri`, the Android build from `android`, the manifest job from the
|
||||
# root — so without this the same request answers differently per caller.
|
||||
#
|
||||
# It is not a tidy failure. Measured on run 4796, one push produced THREE versions:
|
||||
# the desktop build (cwd `desktop/src-tauri`) said 1.0.3494522, while the pacman
|
||||
# packager and the manifest job both said 1.0.3502131. The build's pathspec had
|
||||
# matched `desktop/src-tauri/Cargo.toml` — a real file — so git returned the newest
|
||||
# commit touching THAT, six days stale. Non-empty, so the guard below could not fire;
|
||||
# the manifest then found no bundle matching its own answer and the lane went red for
|
||||
# a reason two steps removed from the cause.
|
||||
#
|
||||
# The Android job failed loudly in the same run only because its pathspec happened to
|
||||
# match nothing from `android/`. Same bug, louder symptom, pure luck.
|
||||
# It is not a tidy failure. From `desktop/src-tauri`, the pathspec `Cargo.toml`
|
||||
# matches `desktop/src-tauri/Cargo.toml`, a real file, so git returns the newest
|
||||
# commit touching THAT: a stale but non-empty answer the guard below cannot catch,
|
||||
# and a version that disagrees with what every other job derives for the same push.
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# A SHALLOW CLONE IS FATAL, and asked directly rather than inferred.
|
||||
#
|
||||
# Landmine §6.1: depth-1 sees one commit, so `git log -- <paths>` answers about
|
||||
# whatever happens to be in that commit and the result is a too-LOW version — the
|
||||
# unrecoverable direction, arrived at silently with every lane green.
|
||||
# Depth-1 sees one commit, so `git log -- <paths>` answers about whatever happens to
|
||||
# be in that commit and the result is a too-LOW version — the unrecoverable
|
||||
# direction, arrived at silently with every lane green.
|
||||
#
|
||||
# The empty-result guard below catches only the case where NOTHING matches. It missed
|
||||
# the worse one: on run 4796 a partial match returned a real, six-days-stale answer.
|
||||
# `--is-shallow-repository` tests the actual hazard instead of a symptom of it, costs
|
||||
# no network, and covers every artifact including the ones with no published value to
|
||||
# compare against.
|
||||
# The empty-result guard below catches only the case where NOTHING matches, not a
|
||||
# partial match returning a real, stale answer. `--is-shallow-repository` tests the
|
||||
# actual hazard instead of a symptom of it, costs no network, and covers every
|
||||
# artifact including the ones with no published value to compare against.
|
||||
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
|
||||
echo "version.sh: this is a SHALLOW clone — any version derived here would be" >&2
|
||||
echo " too low, silently. Add 'fetch-depth: 0' to the checkout." >&2
|
||||
echo " (note 3127 §6.1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2020-01-01T00:00:00Z. The counter epoch, and it must NEVER move: shifting it
|
||||
# renumbers every artifact downwards, which is the one direction you cannot recover
|
||||
# from (note 3127 §6.4).
|
||||
# from.
|
||||
EPOCH=1577836800
|
||||
|
||||
# --- the shipped file sets ---------------------------------------------------
|
||||
#
|
||||
# ONE definition, read by every consumer. The `paths:` filters in the three
|
||||
# workflows are a second, independent statement of the same fact today; they come
|
||||
# out in step 6 when skip-if-exists replaces them. Until then, a change here that is
|
||||
# not mirrored there means a lane that does not fire — check both.
|
||||
# ONE definition, read by every consumer: `should-build.sh` decides from it whether
|
||||
# a client lane builds, and the workflows carry no `paths:` filters of their own.
|
||||
#
|
||||
# Read off what actually PACKAGES each artifact, not off intuition. Miss a file and
|
||||
# a stale build keeps its version; include one that does not ship and you re-version
|
||||
@@ -94,10 +79,11 @@ EPOCH=1577836800
|
||||
#
|
||||
# THE BUILD DEFINITION IS IN THE SET, and it is the part that is easy to leave out.
|
||||
# A workflow file is not "shipped" — but change a Gradle flag or a `cargo tauri
|
||||
# build` argument and the bytes change while the source does not. Once step 6 skips
|
||||
# a build whose version already exists, that combination serves the OLD artifact on
|
||||
# a green run: exactly the "miss a file and a stale build keeps its version" failure,
|
||||
# arriving through the build recipe rather than the source. Same reason `packaging/`
|
||||
# build` argument and the bytes change while the source does not. `should-build.sh`
|
||||
# skips a build whose version the channel already serves, so that combination would
|
||||
# serve the OLD artifact on a green run: exactly the "miss a file and a stale build
|
||||
# keeps its version" failure, arriving through the build recipe rather than the
|
||||
# source. Same reason `packaging/`
|
||||
# is in every set: this script decides identity, so a change to it is a change to
|
||||
# what each artifact claims to be.
|
||||
paths_for() {
|
||||
@@ -110,14 +96,11 @@ paths_for() {
|
||||
# BUNDLED ARTIFACT: the image bakes in the Android client (ci.yml fetches the APK
|
||||
# from the channel release and copies it into the package). So the image's set
|
||||
# must contain the APK's set — an APK-only change genuinely changes what this
|
||||
# image ships. Note 3127 §3 names this trap; FC's web image embeds the extension
|
||||
# the same way.
|
||||
# image ships.
|
||||
#
|
||||
# The base images are NOT listed and do not need to be: `Dockerfile` is in the
|
||||
# set, so pinning `FROM` by digest (step 6) puts the base inside the set for
|
||||
# free. Resolving a digest at derive time would work too and is WRONG — it is an
|
||||
# external lookup, which §7's corollary forbids because it makes the value depend
|
||||
# on when it was computed.
|
||||
# The base images are NOT listed. `Dockerfile` is in the set, so a `FROM` pinned
|
||||
# by digest would bring the base in for free. Resolving a digest at derive time
|
||||
# is WRONG: an external lookup makes the value depend on when it was computed.
|
||||
server) echo "src frontend alembic alembic.ini Dockerfile pyproject.toml .forgejo/workflows/ci.yml android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
|
||||
*) echo "version.sh: unknown artifact '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -126,23 +109,23 @@ paths_for() {
|
||||
# Sets TS to the newest commit timestamp touching this artifact's files, or exits.
|
||||
#
|
||||
# EMPTY IS FATAL, deliberately. A shallow clone sees one commit and derives a
|
||||
# too-low value with every lane green — the failure landmine §6.1 exists for, and
|
||||
# the unrecoverable direction. Every job that calls this needs `fetch-depth: 0`;
|
||||
# too-low value with every lane green — the unrecoverable direction. Every job that
|
||||
# calls this needs `fetch-depth: 0`;
|
||||
# this is what turns forgetting it into a red lane instead of a stranded channel.
|
||||
#
|
||||
# SETS A GLOBAL RATHER THAN ECHOING, and that is not a style preference. Written as
|
||||
# `$(commit_ts desktop)` the function runs in a SUBSHELL, so its `exit` ends only
|
||||
# that subshell and the caller continues with an empty string. Measured before this
|
||||
# was fixed: `key desktop` on a repo with no matching history printed the error to
|
||||
# stderr and then emitted `1.0.-26297280` and exited ZERO. A guard that reports a
|
||||
# problem and does not stop is worse than none — it looks like it is working.
|
||||
# `$(commit_ts desktop)` the function would run in a SUBSHELL, so its `exit` would end
|
||||
# only that subshell and the caller would continue with an empty string: `key
|
||||
# desktop` on a repo with no matching history would print the error and then emit
|
||||
# `1.0.-26297280` and exit ZERO. A guard that reports a problem and does not stop is
|
||||
# worse than none — it looks like it is working.
|
||||
resolve_ts() {
|
||||
# Unquoted on purpose: the path list is several words.
|
||||
# shellcheck disable=SC2046
|
||||
TS="$(git log --format=%ct -1 HEAD -- $(paths_for "$1"))"
|
||||
if [ -z "$TS" ]; then
|
||||
echo "version.sh: no commit touches $1's file set — is this a shallow clone?" >&2
|
||||
echo " (needs fetch-depth: 0; see note 3127 §6.1)" >&2
|
||||
echo " (needs fetch-depth: 0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -154,18 +137,12 @@ artifact="${2:?usage: version.sh <display|key|paths> <desktop|android|server>}"
|
||||
|
||||
# VALIDATED HERE, in the parent shell, and not left to `paths_for`'s default arm.
|
||||
#
|
||||
# Third instance of one trap in this script, so it is worth stating plainly: `exit`
|
||||
# inside a function called as `$(...)` ends the SUBSHELL, not the script. `paths_for`
|
||||
# is reached through `$(paths_for "$1")`, so its `exit 2` printed the error and
|
||||
# returned an EMPTY pathspec — and an empty pathspec matches everything, so
|
||||
# `version.sh display nope` answered `2026.08.28.0900` and exited 0. A confident
|
||||
# version for an artifact that does not exist.
|
||||
#
|
||||
# The other two were the shallow-clone guard on the `key` path (emitted
|
||||
# `1.0.-26297280`, exit 0) and the same guard on `display` (which failed only because
|
||||
# `date` then choked on an empty string — luck, not design). Each was found by a
|
||||
# different mechanism; none by reading the code. If you add a guard to this file,
|
||||
# make sure it runs where the script does.
|
||||
# The trap this whole script works around: `exit` inside a function called as
|
||||
# `$(...)` ends the SUBSHELL, not the script. `paths_for` is reached through
|
||||
# `$(paths_for "$1")`, so its `exit 2` alone would print the error and return an
|
||||
# EMPTY pathspec — and an empty pathspec matches everything, so `version.sh display
|
||||
# nope` would answer a confident version for an artifact that does not exist. If you
|
||||
# add a guard to this file, make sure it runs where the script does.
|
||||
case "$artifact" in
|
||||
desktop|android|server) : ;;
|
||||
*)
|
||||
@@ -181,7 +158,7 @@ case "$what" in
|
||||
|
||||
display)
|
||||
# One shape for every human-readable version in this repo, and for the release
|
||||
# tag: YYYY.MM.DD.HHMM, zero-padded, UTC (note 3127 §1). Padded so it sorts as
|
||||
# tag: YYYY.MM.DD.HHMM, zero-padded, UTC. Padded so it sorts as
|
||||
# text as well as numerically, and so two lanes cannot emit forms one character
|
||||
# apart.
|
||||
resolve_ts "$artifact"
|
||||
@@ -193,18 +170,17 @@ case "$what" in
|
||||
desktop)
|
||||
# COMMIT time. The desktop is a one-value system to Tauri — its comparator
|
||||
# reads the version name — so this key is also what lands in bundle
|
||||
# filenames and .deb metadata. Commit time buys the property in §2 reason
|
||||
# (4): the last dev build before a PR and the main build from it are the
|
||||
# same bytes and derive the same key, so the artifact is reused rather than
|
||||
# rebuilt and re-signed under a new name.
|
||||
# filenames and .deb metadata. With commit time, the last dev build before
|
||||
# a PR and the main build from it are the same bytes and derive the same
|
||||
# key, so the artifact is reused rather than rebuilt and re-signed under a
|
||||
# new name.
|
||||
#
|
||||
# Commit time CAN go backwards (rebuild an older commit). The backwards
|
||||
# guard in step 5 is the whole mitigation, and the desktop's failure there
|
||||
# is soft: an update is not offered. Contrast Android below.
|
||||
# Commit time CAN go backwards (rebuild an older commit). guard-forward.sh
|
||||
# is the whole mitigation, and the desktop's failure there is soft: an
|
||||
# update is not offered. Contrast Android below.
|
||||
#
|
||||
# `1.0.` and not `0.0.`: the minor must clear the installed `0.2.<run>` line
|
||||
# or every dev user is stranded on "up to date" permanently. Checked against
|
||||
# the live feed (0.2.466), not against what we thought we had published.
|
||||
# `1.0.` and not `0.0.`: the minor must clear the `0.2.<run>` line older
|
||||
# installs carry, or they are stranded on "up to date" permanently.
|
||||
resolve_ts desktop
|
||||
echo "1.0.$(minutes_since_epoch "$TS")"
|
||||
;;
|
||||
@@ -222,8 +198,8 @@ case "$what" in
|
||||
server)
|
||||
# NO ORDERING KEY. Nothing compares the server image: no updater, no install
|
||||
# gate, and `:latest` is moved by the registry rather than chosen by a
|
||||
# client. §2 is explicit that an artifact with nothing to compare needs only
|
||||
# a name — do not add one because the other two have one.
|
||||
# client. An artifact with nothing to compare needs only a name — do not
|
||||
# add one because the other two have one.
|
||||
echo "version.sh: the server has no ordering key; use 'display'" >&2
|
||||
exit 2
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user