AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
+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