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

#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:
2026-10-21 20:44:02 -04:00
co-authored by Claude Opus 5.5
parent 01b53118b9
commit f16224b07c
18 changed files with 666 additions and 843 deletions
+34 -44
View File
@@ -1,11 +1,7 @@
name: Android
# The native Kotlin/Compose client over the shared Rust core (M12).
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
# uniffi.
# The native Kotlin/Compose client over the shared Rust core. The store and sync
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through uniffi.
#
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
# about feel, touch or on-device correctness is an operator pass on an emulator or
@@ -15,12 +11,12 @@ name: Android
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
# signed with different keys cannot replace one another, and bridging that gap
# means uninstalling first — which deletes the app's database and every local note
# with it (Scribe issue 2803).
# with it.
on:
push:
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
# See desktop.yml for why, and 85ead4d for what the duplication cost.
# See desktop.yml for why.
branches: [dev, main]
workflow_dispatch:
@@ -32,15 +28,15 @@ env:
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
# gradle.properties is not enough on its own (Minstrel hit the same thing).
# gradle.properties is not enough on its own.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
# Does the APK need rebuilding, or is the channel already serving this source?
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
# See the equivalent job in desktop.yml — same reasoning, the same one file set in
# `packaging/version.sh`.
#
# The guard runs here so it covers the skip path too (§6.3).
# The guard runs here so it covers the skip path too.
#
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
@@ -74,12 +70,12 @@ jobs:
# The core ships inside the APK (through android/ffi), so its checks have to gate
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
# there cannot stop this lane publishing (rule 177, #5237).
# there cannot stop this lane publishing.
#
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
# image has OpenSSL vendored for the Android targets alone, so `openssl-sys` fails
# there. Same Rust pin, same Cargo.lock.
rust:
name: Core and FFI clippy and tests
needs: [decide]
@@ -100,11 +96,11 @@ jobs:
name: Kotlin + Rust (APK)
needs: [decide, rust]
if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
# runs-on is only a scheduling label. flutter-ci is the
# proven-working label that can pull our container images.
runs-on: flutter-ci
container:
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
# Rust + the four
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
@@ -119,7 +115,7 @@ jobs:
- uses: actions/checkout@v4
with:
# Derives a version, so it needs the whole history — see the note in
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
# desktop.yml. Depth-1 is silently wrong here, not loudly broken.
fetch-depth: 0
- name: Cache Gradle and Cargo
@@ -143,7 +139,7 @@ jobs:
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
# TWO CLOCKS, ON PURPOSE. The NAME answers "is this the
# same code?", so it comes from the COMMIT and a dev build and the main
# build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because
@@ -161,7 +157,7 @@ jobs:
echo "label=release" >> $GITHUB_OUTPUT
# An optimised .so for the APK people install. The cargoNdk task keeps
# the symbols uniffi reads its metadata from, which the release
# profile would strip (run 4077, Scribe #2810).
# profile would strip.
echo "profile=release" >> $GITHUB_OUTPUT
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
@@ -190,7 +186,7 @@ jobs:
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
# (M12 step 3) precisely so this lane needs no second image, and going
# precisely so this lane needs no second image, and going
# through Gradle plugins would mean a second version of each tool resolved
# at build time and kept in lockstep with the image's by hand.
#
@@ -210,7 +206,7 @@ jobs:
#
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
# not exist (run 4082). It costs one extra Kotlin compile and buys the
# not exist. It costs one extra Kotlin compile and buys the
# type-check on the debug variant, which is the one an emulator build
# would use.
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
@@ -233,16 +229,15 @@ jobs:
# simply refuses to install over the app already on the phone — a failure
# that otherwise only shows up on the device, after the run is green.
#
# And FAILS unless there is exactly one signer and it is the release key
# (family idea #5103, practice 3). Printing alone was not a check: a build
# signed with any other key would have gone on to be staged and published,
# and every phone would have refused it. The steps that publish come after
# this one in the same job, so a failure here stops them.
# And FAILS unless there is exactly one signer and it is the release key.
# Printing alone is not a check: a build signed with any other key would go on
# to be staged and published, and every phone would refuse it. The steps that
# publish come after this one in the same job, so a failure here stops them.
- name: Check the signing certificate
if: steps.build.outputs.keystore != ''
env:
# The release certificate's SHA-256, as apksigner printed it for the
# signed APK in run 8753 (CN=Bryan Van Deusen, O=fabledsword). Every
# The release certificate's SHA-256, as apksigner prints it for a signed
# APK (CN=Bryan Van Deusen, O=fabledsword). Every
# installed copy carries this certificate. It only changes if the key
# does, and then every install has to be replaced by hand anyway.
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
@@ -285,10 +280,9 @@ jobs:
# publishing an unsigned APK would offer people something they cannot install
# over what they already have.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
# Publishing `stable` from main is what lets the server image bake in a client
# that matches its own channel: a :latest image fetches the APK from `stable`, a
# :dev image from `dev`.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
@@ -300,18 +294,17 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# success while Gitea serves artifacts back only through the v4 API, so the
# upload is stored and invisible (Scribe 2270).
# Stock action: it works on this forge because gitea/runner 3.x edits
# upload-artifact's client-side GHES refusal out of the action bundle. Never
# @v3 — it reports success while Gitea serves artifacts back only through the
# v4 API, so the upload is stored and invisible.
uses: actions/upload-artifact@v7
with:
# The APK's variant, NOT the Cargo profile. They are the same word for
@@ -338,11 +331,8 @@ jobs:
# from here — that is the other half of this.
#
# No `always()`: a red core check or APK build means no image for this
# commit (rule 177, #5419). This used to run regardless, on the reasoning that
# a broken Android lane shouldn't block server delivery, and the image would
# bake in the previous client. Run 8839 is what that meant: `:<sha>` published
# for 0ecdbfe while its APK lane (8835) was red, holding a client that wasn't
# from that commit. The previous `:dev` stays where it is; fix forward.
# commit. Building regardless would publish a `:<sha>` holding a client that
# isn't from that commit. The previous `:dev` stays where it is; fix forward.
#
# Not skipped on tags: every ref that builds an image needs the call, since
# ci.yml stands down on these pushes and nothing else builds one.
+47 -63
View File
@@ -3,22 +3,21 @@
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha>
#
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
# nothing pins one — verified by looking for a consumer, not for whether one is
# imaginable — and the git release tag is a different object in a different system
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
# THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing
# pins one, and the git release tag is a different object in a different system.
# The image is addressed by CHANNEL or by COMMIT; the release by date.
#
# A `v*` tag builds nothing at all. The merge to main already published everything,
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
# which rule 145 forbids even when they match.
# and a tag rebuilding that same source would re-push :<sha>, which a published tag
# never allows.
#
# main is the production line, so a merge moves :latest to its tip (family rule 46)
# — no version release required. :<sha> is the immutable rollback unit, and it is
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
# accumulating forever, for a channel whose entire contract is that it moves.
# main is the production line, so a merge moves :latest to its tip — no version
# release required. :<sha> is the immutable rollback unit, and it is on main ONLY: a
# sha tag per dev push is a rollback target nobody pulls, accumulating forever, for
# a channel whose entire contract is that it moves.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
# REGISTRY_TOKEN -- forge PAT with write:packages scope
# The registry username is derived from github.repository_owner (public — it's in
# the image path), so no REGISTRY_USER secret is needed.
name: CI & Build
@@ -28,13 +27,12 @@ on:
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
# the image ALWAYS builds. Two reasons:
#
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
# production commit is addressable. A path filter quietly broke that promise
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
# * Every push to `main` publishes a `:<sha>`, so any production commit is
# addressable. A path filter would break that for a docs-only merge: no
# trigger, no image, no sha tag for that commit.
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
# is a floating tag and this can face the internet), and building every push
# picks those updates up. That is why note 3127 §4's base tension does not
# bite here — the one artifact it would apply to never skips.
# picks those updates up.
#
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
# for the clients, which is why THEY skip and this does not.
@@ -62,17 +60,17 @@ jobs:
#
# A push touching the Android app runs BOTH workflows at once. Building here
# would bake in the PREVIOUS client and then, when the new one landed, there
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
# would be no second build — `:<sha>` is the immutable rollback unit
# and rebuilding it with different content would make it neither.
#
# So on such a push this workflow stands down, and the Android lane dispatches it
# when it is finished. Exactly one image per commit, containing the client from
# that commit.
#
# The path list below MUST match android.yml's trigger. Two places holding one
# decision is the recurring failure in this repo (issues 2181-2183); it is here
# because a workflow cannot read another's filters, and it is a `git diff` rather
# than a config so at least it is inspectable in the log.
# The path list below MUST match android's file set in packaging/version.sh. Two
# places holding one decision drift apart; it is here because this gate needs the
# answer before the Android lane has decided, and it is a `git diff` rather than a
# config so at least it is inspectable in the log.
gate:
name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
@@ -125,11 +123,10 @@ jobs:
echo "Changed in this push:"
echo "$changed" | sed 's/^/ /'
# MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
# MUST match android's file set in packaging/version.sh. A path missing here
# lets a push touching only it rebuild and dispatch from the Android lane
# while this gate ALSO lets the image build: two images for one commit and,
# on main, a second push of the same :<sha> with different bytes.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo ""
echo "This push also changes the Android client. Standing down: the"
@@ -145,8 +142,8 @@ jobs:
# The web's whole verification: the type check, then its unit tests. Both in the
# job `build` already needs, so a red web test blocks the image like any other
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
# lane. Among other things they guard the web copy of the note grammar, which is
# mirrored in Rust and Python.
typecheck:
name: Web typecheck and unit tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
@@ -203,17 +200,16 @@ jobs:
# and runs in the job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
# for the first time when the operator's container started — 26 revisions, none of
# them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it.
# Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs
# it, and the schema the migrations build is checked against the models that read
# it.
#
# Gates the build, along with every other lane — see the `build` job's `needs`.
#
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
# Job key stays separator-free ("integration") with no `name:`. act_runner
# derives the service-container name from the truncated job display name, and the
# discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP.
# on this runner, so the step resolves the container's bridge IP.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
@@ -251,8 +247,8 @@ jobs:
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
# Only THIS job's services. The runner's docker daemon is shared, so another
# repo's job can be running beside this one, and a job-name filter matches its
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
# containers too, and would take another repo's Postgres. act names every
# container of a task GITEA-ACTIONS-TASK-<n>-...: read
# <n> from our own container (its id is in the /etc/hostname bind mount) and
# require exactly one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
@@ -264,7 +260,7 @@ jobs:
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
# Wait for Postgres to accept connections. `run:` is busybox sh —
# no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
@@ -277,7 +273,7 @@ jobs:
else:
sys.exit("postgres did not become reachable")
PY
# Real migrations build the schema, never metadata.create_all (rule 82) —
# Real migrations build the schema, never metadata.create_all —
# testing a schema no deployment has ever seen would prove nothing. This
# step IS the migration test: a broken revision fails the job here.
/opt/venv/bin/alembic upgrade head
@@ -285,15 +281,9 @@ jobs:
build:
name: Build & push image
# Every lane gates the build. This once stopped at lint + typecheck, on the
# reasoning that DB-backed testing happened manually against the dev image
# rather than on every push — true until 6f21db8 added the integration lane,
# and false since.
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
# Every lane gates the build. An image tag is the rollback substrate, and one
# that can be published from a failing run is not a substrate you can roll back
# TO.
needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true'
runs-on: python-ci
@@ -306,22 +296,20 @@ jobs:
- uses: actions/checkout@v6
with:
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
# and produces a too-low value silently, with the lane green (§6.1).
# and produces a too-low value silently, with the lane green.
fetch-depth: 0
- name: Generate image tags and version
id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# run: steps execute under busybox sh, so use POSIX `case`,
# NOT bash `[[ ]]`.
run: |
# The image's version is DERIVED from its own shipped files — including the
# Android client it bakes in, which is why an APK-only change re-versions
# it. One value and no ordering key: nothing compares a server image, so
# §2 says do not invent one just because the other artifacts have one.
# it. One value and no ordering key: nothing compares a server image.
#
# This was a short sha on main and the literal "dev" elsewhere, which could
# not answer "how old is this instance?" — the question that actually gets
# asked of a self-hosted app running in several places.
# Date-shaped, because "how old is this instance?" is the question that
# actually gets asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in
refs/heads/dev)
@@ -345,11 +333,11 @@ jobs:
# Bake EVERY client in, on every image build, so a self-hoster gets a working
# app for their machine from the server holding their notes — without an
# account on this forge, which is private (issue 2091) and is why serving them
# from a release page was never an option for anybody but the operator.
# account on this forge, which is private, so its release pages serve nobody
# but the forge's own users.
#
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# the price of the product being complete (rule 23), and the AppImage is not
# the price of the product being complete, and the AppImage is not
# optional within it: it is the ONLY bundle that can replace itself in place,
# so a server without one cannot serve in-app updates to anyone.
#
@@ -367,12 +355,8 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally
# until M314 step 3, on every branch — so every stable server shipped a
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
# :latest serves stable ones. The channel is chosen here rather than inside
# the script because the caller is what knows which image it is building.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
+55 -74
View File
@@ -4,22 +4,20 @@
# It DOES run on frontend changes. tauri's generate_context! embeds the built
# frontend in the binary, so a frontend commit that never triggers this ships to
# the web and silently never reaches the desktop app — and desktop, web and Android
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
# isn't there.
# are peer surfaces held to one quality bar, not a primary and its fallbacks. A
# build takes 4-5 minutes, so there is no cost worth narrowing that for.
#
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
# runs-on is just a registered scheduling label, not a per-purpose
# runner. The frontend is built here because tauri's generate_context! embeds it.
name: Desktop (Tauri)
on:
push:
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
# from it within a day (85ead4d). The `decide` job below reads the real set and
# skips in seconds when nothing moved — one definition, one reader (§3).
# NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's
# file set beside the one in `packaging/version.sh`, and the two drift. The
# `decide` job below reads the real set and skips in seconds when nothing moved —
# one definition, one reader.
#
# The cost is that this workflow starts on every push rather than on a matching
# one. That is a ~15s container for a decision, against a lane that cannot
@@ -40,13 +38,9 @@ permissions:
jobs:
# Does anything need building at all?
#
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
# the `paths:` filters that used to state the same fact a second time. They drifted
# from it within a day: `packaging/` was added to the sets and not to the filters,
# so the commit fixing a derivation bug never ran on the two lanes it fixed
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
# ONE reader of ONE definition: the file sets in `packaging/version.sh`.
#
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one.
# Skipping because "the channel already serves this version" is indistinguishable
# from "we derived a stale value that happens to match" unless something checks.
decide:
@@ -60,7 +54,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Derives a version — depth-1 is silently wrong (§6.1).
# Derives a version — depth-1 is silently wrong.
fetch-depth: 0
- name: Decide
@@ -76,13 +70,12 @@ jobs:
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
# them (rule 177: nothing publishes on red).
# them: nothing publishes on red.
#
# They used to be steps inside `build`, which gated the Linux publish and nothing
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
# stopped, and the Windows installer built and published to the dev release in the
# same minute (#5184). An edge from each publisher to this job is the gate; a
# verdict inside a sibling job is only a report.
# As steps inside `build` they would gate the Linux publish and nothing else, and
# the Windows installer would publish beside a failed clippy. An edge from each
# publisher to this job is the gate; a verdict inside a sibling job is only a
# report.
#
# Both publishers need it directly rather than through each other, so a Windows
# failure still never blocks the Linux bundles, and neither waits on the other's
@@ -108,25 +101,20 @@ jobs:
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
# a red web test would still let both installers publish (rule 177).
# a red web test would still let both installers publish.
- name: Web unit tests
run: npm test
working-directory: frontend
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
# re-resolving. Both publishing jobs need this job, so neither
# bundles a commit whose lockfile drifted.
#
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
#
# These three steps used to run inside the desktop crate, which was right when
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
# all. Both crates are dependencies of the desktop, so they still COMPILED —
# which is exactly why the gap was invisible, and why a green run kept meaning
# less than it looked like it meant.
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri. Inside the
# desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not
# run and nothing would lint the Android uniffi shim, while both still COMPILE
# as dependencies — so the run stays green and the gap stays invisible.
- name: Clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings
@@ -148,8 +136,8 @@ jobs:
name: Tauri desktop (Linux)
needs: [decide, verify]
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
# runs to the runner's own 30-minute cap, as run 8830 did on a rust-lld link whose
# threads had all deadlocked (#5409). Kept under that cap so this one is the limit
# runs to the runner's own 30-minute cap, as a rust-lld link whose threads have
# all deadlocked will. Kept under that cap so this one is the limit
# that fires, whatever the runner is configured with.
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
@@ -166,7 +154,7 @@ jobs:
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# the lane green — the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
@@ -180,10 +168,10 @@ jobs:
# Frontend already built above; skip the beforeBuildCommand rebuild.
#
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
# tauri FAILS the build if it's asked to produce updater artifacts with no key,
# so making it conditional is what lets the pipeline stay green before the
# operator has added the secret. With the key present, each bundle gets a
# createUpdaterArtifacts is applied only when a signing key exists: tauri
# FAILS the build if it's asked to produce updater artifacts with no key, so
# making it conditional is what lets the pipeline stay green without the
# secret. With the key present, each bundle gets a
# `.sig` beside it — the file the updater actually verifies against.
- name: Tauri build (deb + AppImage)
env:
@@ -200,10 +188,10 @@ jobs:
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
# separate value, exported just below.
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# The DISPLAY version, baked into the binary by `option_env!`.
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
@@ -219,7 +207,7 @@ jobs:
# Tauri's AppImage bundles the build host's graphics/display libs
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU
# drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021).
# drivers and abort to a black window (EGL_BAD_PARAMETER).
# Strip that host-coupled stack so the app uses the running system's
# graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
# script resolves its own paths), overwriting the AppImage in place.
@@ -253,9 +241,9 @@ jobs:
# control file and cross-checks it against what the ELF actually needs
# (dpkg-shlibdeps).
#
# We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed
# tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so
# declaring them again only produced a control file listing each twice.
# We deliberately do NOT set bundle.linux.deb.depends: tauri already infers
# exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only
# produces a control file listing each twice.
# This step is the guard instead — if tauri's inference ever stops
# covering what the binary links, the build fails here.
- name: Verify the .deb
@@ -271,13 +259,12 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
# something Gitea stores but will never serve back (it returns artifacts only
# through the v4 API, which filters on content_encoding='application/zip').
# No continue-on-error: a swallowed upload failure is exactly how 110
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
# Stock action: it works on this forge because gitea/runner 3.x edits
# upload-artifact's client-side GHES refusal out of the action bundle. Never
# @v3 — it uploads something Gitea stores but will never serve back (it returns
# artifacts only through the v4 API, which filters on
# content_encoding='application/zip'). No continue-on-error: a swallowed upload
# failure lets unreachable artifacts pile up unnoticed. Fail loudly instead.
- name: Upload bundles
uses: actions/upload-artifact@v7
with:
@@ -292,14 +279,11 @@ jobs:
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
# reaches stable users with no tag cut.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
@@ -315,7 +299,7 @@ jobs:
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
@@ -323,7 +307,7 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
@@ -355,7 +339,7 @@ jobs:
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# the lane green — the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
@@ -396,10 +380,10 @@ jobs:
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
# separate value, exported just below.
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# The DISPLAY version, baked into the binary by `option_env!`.
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
@@ -430,14 +414,11 @@ jobs:
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
# reaches stable users with no tag cut.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
@@ -453,7 +434,7 @@ jobs:
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
@@ -461,7 +442,7 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
@@ -488,7 +469,7 @@ jobs:
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# the lane green — the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
@@ -519,7 +500,7 @@ jobs:
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
#
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
# No tag arm: a `v*` tag does not reach this workflow at all — it
# triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
+6 -6
View File
@@ -1,26 +1,26 @@
name: Release
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
# A RELEASE BUILDS NOTHING. That is the whole point of this lane.
#
# The merge to `main` already published everything a user can receive: the server
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
# channel, and the updater manifest that advertises them. A tag rebuilding that same
# source would produce identical artifacts under identical names, and would re-push
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
# `:<sha>`, which a published tag never allows.
#
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
# what was in it. Note 3127 §5 — there are two halves to "what am I running", and
# what was in it. There are two halves to "what am I running", and
# the version answers only the first:
#
# which build is this? the footer, /api/config, the APK's versionName
# what changed since the one ← this
# I was running last month?
#
# Cutting the tag is the operator's act (rule 2). This only responds to one.
# This lane never cuts a tag; it only responds to one.
#
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
# tags at all. The image is addressed by channel or by commit; the release by date.
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
# Same string as the artifact version (`vYYYY.MM.DD.HHMM`), different
# system.
on:
@@ -62,7 +62,7 @@ jobs:
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
# release that is only ever POSTed keeps whatever body its first run
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
# wrote, so re-tagging or re-running must rewrite it. No bundles
# exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh