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