diff --git a/.env.example b/.env.example index eea4dd5..f32aa8f 100644 --- a/.env.example +++ b/.env.example @@ -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 -# 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://`. diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index 529e013..e055e56 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -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: `:` 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 `:` 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. diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9b90467..4b94394 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,22 +3,21 @@ # Push to dev: typecheck + lint + test + build :dev # Push to main: typecheck + lint + test + build :latest + : # -# 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 : with different bytes, -# which rule 145 forbids even when they match. +# and a tag rebuilding that same source would re-push :, 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. : 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. : 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 `:`, 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 `:`, 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 — `:` is the immutable rollback unit (rule 46) + # would be no second build — `:` 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 : 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 : 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--...: read + # containers too, and would take another repo's Postgres. act names every + # container of a task GITEA-ACTIONS-TASK--...: read # 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 : - # 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 ;; diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 974968b..581f1f4 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -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 -- ` 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/ route, so "newest" + # read — the forge has no /releases/latest/download/ 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 -- ` 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/ route, so "newest" + # read — the forge has no /releases/latest/download/ 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 -- ` 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)" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a647444..e4adb4d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -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` + `:`, the desktop bundles and the APK to the `stable` # channel, and the updater manifest that advertises them. A tag rebuilding that same # source would produce identical artifacts under identical names, and would re-push -# `:` with different bytes — which rule 145 forbids even when they match. +# `:`, 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 diff --git a/Dockerfile b/Dockerfile index 595297a..223a32c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 62a4d46..031e683 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,41 @@ -# Inkwell +

+ Inkwell logo +

-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). +

Inkwell

-- **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. +

Capture a thought in under a second, and find it again later.

-## 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://:5000` and register — **the first account becomes the admin**. +Open `http://: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) · `:` 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://: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:`. 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:///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. diff --git a/ci-requirements.md b/ci-requirements.md index 21c08e6..54d01b0 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -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 `:`. 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` + `:`; `v*` tag -> `:latest` + `:` + `:` - (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' - -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' + +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/` 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/` 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 diff --git a/docs/android-distribution.md b/docs/android-distribution.md index b11326f..c7daa61 100644 --- a/docs/android-distribution.md +++ b/docs/android-distribution.md @@ -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. diff --git a/docs/sync.md b/docs/sync.md index 86838c4..d4f542a 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -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. diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg index 43018f6..6074487 100644 --- a/frontend/public/icon.svg +++ b/frontend/public/icon.svg @@ -1,6 +1,6 @@ + quill standing in it. Source for every raster icon. --> diff --git a/packaging/channel-tag.sh b/packaging/channel-tag.sh index 558474a..db2b2e0 100644 --- a/packaging/channel-tag.sh +++ b/packaging/channel-tag.sh @@ -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 diff --git a/packaging/fetch-clients.sh b/packaging/fetch-clients.sh index fefd3a8..94e43d7 100755 --- a/packaging/fetch-clients.sh +++ b/packaging/fetch-clients.sh @@ -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" diff --git a/packaging/guard-forward.sh b/packaging/guard-forward.sh index da8699f..fd16be4 100755 --- a/packaging/guard-forward.sh +++ b/packaging/guard-forward.sh @@ -6,18 +6,17 @@ # guard-forward.sh compare exit 0 iff a sorts below b # guard-forward.sh published 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 diff --git a/packaging/icons.py b/packaging/icons.py index b1b47f7..02aa61f 100644 --- a/packaging/icons.py +++ b/packaging/icons.py @@ -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''' + quill standing in it. Source for every raster icon. --> {GLYPH} ''') diff --git a/packaging/release-notes.sh b/packaging/release-notes.sh index 432d012..e02b7d4 100755 --- a/packaging/release-notes.sh +++ b/packaging/release-notes.sh @@ -4,12 +4,11 @@ # # release-notes.sh # -# A release BUILDS NOTHING now (M314 step 7). The merge to `main` already published -# `:latest`, `:` and both channel feeds, so a tag rebuilding that same source -# would produce identical artifacts and re-push `:` with different bytes — -# which rule 145 forbids even when the bytes match. +# A release BUILDS NOTHING. The merge to `main` already published `:latest`, +# `:` and both channel feeds, and a tag rebuilding that same source would +# re-push `:`, 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 diff --git a/packaging/should-build.sh b/packaging/should-build.sh index 1136bd3..913a677 100755 --- a/packaging/should-build.sh +++ b/packaging/should-build.sh @@ -5,27 +5,21 @@ # # should-build.sh # -# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by -# VERSION and rule 145 removed exactly that. There is no `:` 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 `:` 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 diff --git a/packaging/version.sh b/packaging/version.sh index d6beafc..896e493 100755 --- a/packaging/version.sh +++ b/packaging/version.sh @@ -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 the human-readable version — 2026.08.28.1815 # version.sh key 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 -- ` 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 -- ` 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 }" # 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.` 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.` 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 ;;