ci: one definition of what ships decides both the version and whether to build
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Step 6 of M314. Two changes that only make sense together.
## The image tag set rule 145 mandates
dev push -> :dev
main push -> :latest + :<sha>
a v* tag -> nothing; the trigger is gone
`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.
No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.
## Skip-if-exists, adapted, because §4 assumes a registry §5 removed
Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.
So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(85ead4d). One definition, one reader.
The cost is that both workflows now start on every push rather than a matching
one — a ~15s container for a decision, against a lane that cannot silently fail
to run.
## The server always builds, deliberately
Its image is ~15 seconds against 6 and 9 minutes for the clients, so there is
little to save. And always building is strictly BETTER for something that can
face the internet: it picks up `python:3.12-slim` base updates on every push.
That also dissolves §4's base-image tension for this project rather than
deciding it — the artifact most exposed to base staleness is the one that never
skips. Resolving a base digest at derive time was the alternative and it is
forbidden: §7's corollary bars an external lookup, because two lanes would then
derive different values for one source.
## The guard runs on the skip path
It moved into `decide`, ahead of the decision. §6.3 is explicit that skipping
because "this version already exists" is indistinguishable from "we derived a
stale value that happens to match" unless something checks. It also now runs
once per lane instead of once per job.
## Two defects found while wiring this
`ci.yml`'s gate greps a path list that MUST match Android's file set, and
`packaging/` was missing from it. A packaging-only push would have had the
Android lane build and dispatch while the gate ALSO let the image through —
two images for one commit, and on main a second push of the same `:<sha>` with
different bytes. Rule 145's exact prohibition.
`guard-forward.sh` ends every fetch in `|| true`, so a runner image without
curl would have read as "nothing published yet" and passed without checking
anything. Missing curl is now fatal.
#3146
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,18 +19,9 @@ name: Android
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
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.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
paths:
|
|
||||||
- "android/**"
|
|
||||||
# The Rust the .so is built from. A core change reaches the phone exactly
|
|
||||||
# as it reaches the desktop, so this lane has to rebuild on it.
|
|
||||||
- "core/**"
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
# The version deriver — see the note in desktop.yml. This lane did not run on
|
|
||||||
# 85ead4d, which changed it.
|
|
||||||
- "packaging/**"
|
|
||||||
- ".forgejo/workflows/android.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -45,8 +36,46 @@ env:
|
|||||||
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?
|
||||||
|
# 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`.
|
||||||
|
#
|
||||||
|
# The guard runs here so it covers the skip path too (§6.3).
|
||||||
|
#
|
||||||
|
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
|
||||||
|
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
||||||
|
# happens — and that is correct, because ci.yml's `gate` stands down only when the
|
||||||
|
# push touched Android's files, which is the same condition that makes this build.
|
||||||
|
# The two decisions agree because they read the same fact; they are still two
|
||||||
|
# readers of it, which is why the gate's grep carries a comment pointing here.
|
||||||
|
decide:
|
||||||
|
name: Build, or is the channel already serving this?
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.d.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: d
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/guard-forward.sh android "$channel"
|
||||||
|
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Kotlin + Rust (APK)
|
name: Kotlin + Rust (APK)
|
||||||
|
needs: [decide]
|
||||||
|
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 (Label Model B). 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
|
||||||
@@ -126,26 +155,6 @@ jobs:
|
|||||||
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
|
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
|
|
||||||
# lane derives a number and trusts it; this compares the derived value against
|
|
||||||
# what the channel is actually serving, and fails the lane if it went DOWN.
|
|
||||||
#
|
|
||||||
# Placed before the build, not after: a bad derivation should cost seconds, not
|
|
||||||
# a five-minute compile and a publish that has to be undone. Too-low is the
|
|
||||||
# unrecoverable direction — every installed client reports "up to date" forever
|
|
||||||
# and no later build fixes it until one climbs back above the bad number
|
|
||||||
# (#2183, #2993).
|
|
||||||
- name: Guard — the version must not go backwards
|
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${ github.token }
|
|
||||||
run: |
|
|
||||||
case "$GITHUB_REF_NAME" in
|
|
||||||
main) channel=stable ;;
|
|
||||||
*) channel=dev ;;
|
|
||||||
esac
|
|
||||||
sh ../packaging/guard-forward.sh android "$channel"
|
|
||||||
|
|
||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
|||||||
+35
-36
@@ -1,12 +1,21 @@
|
|||||||
# CI runs first; build only proceeds if lint + typecheck pass.
|
# CI runs first; build only proceeds if lint + typecheck pass.
|
||||||
#
|
#
|
||||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
# 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>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
|
||||||
#
|
#
|
||||||
# main is the production line, so a merge to main rebuilds and moves :latest to its
|
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
||||||
# tip (family rule 46) — no version release required. The :<sha> image is the
|
# nothing pins one — verified by looking for a consumer, not for whether one is
|
||||||
# immutable rollback unit for every build.
|
# 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.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
#
|
#
|
||||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||||
@@ -17,7 +26,6 @@ name: CI & Build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
@@ -34,11 +42,10 @@ on:
|
|||||||
# `gate` job below for the other half.
|
# `gate` job below for the other half.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
|
# Cancel older runs on the same branch when a newer push lands.
|
||||||
# own group implicitly and are never cancelled.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -66,7 +73,7 @@ jobs:
|
|||||||
# than a config so at least it is inspectable in the log.
|
# than a 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' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -91,17 +98,6 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A tag. The Android lane does not run on tags, so nothing would ever
|
|
||||||
# call back — standing down here would mean a release tag that never
|
|
||||||
# produces an image at all.
|
|
||||||
case "${{ github.ref }}" in
|
|
||||||
refs/tags/*)
|
|
||||||
echo "Tag build — the Android lane does not run on tags. Building."
|
|
||||||
echo "build=true" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
||||||
# compare, so build rather than stall.
|
# compare, so build rather than stall.
|
||||||
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
||||||
@@ -127,7 +123,12 @@ jobs:
|
|||||||
echo "Changed in this push:"
|
echo "Changed in this push:"
|
||||||
echo "$changed" | sed 's/^/ /'
|
echo "$changed" | sed 's/^/ /'
|
||||||
|
|
||||||
if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
# 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.
|
||||||
|
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"
|
||||||
echo "Android lane will publish a new APK and dispatch this workflow,"
|
echo "Android lane will publish a new APK and dispatch this workflow,"
|
||||||
@@ -142,7 +143,7 @@ jobs:
|
|||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript typecheck
|
name: TypeScript typecheck
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -159,7 +160,7 @@ jobs:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Python lint
|
name: Python lint
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -172,7 +173,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -202,7 +203,7 @@ jobs:
|
|||||||
# 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 (rule 79), so the step resolves the container's bridge IP.
|
||||||
integration:
|
integration:
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -291,7 +292,6 @@ jobs:
|
|||||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
||||||
# NOT bash `[[ ]]`.
|
# NOT bash `[[ ]]`.
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
|
||||||
# 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, so
|
||||||
@@ -303,15 +303,14 @@ jobs:
|
|||||||
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)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
refs/heads/main)
|
refs/heads/main)
|
||||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
|
||||||
# the :<sha> above is the rollback unit.
|
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
|
||||||
;;
|
;;
|
||||||
refs/tags/*)
|
*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
echo "::error::This lane builds images for dev and main only."
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||||
@@ -349,8 +348,8 @@ jobs:
|
|||||||
# from it. Not a versioning gap; a plain defect, fixed here because this
|
# from it. Not a versioning gap; a plain defect, fixed here because this
|
||||||
# is the step that gave `stable` an APK to point at.
|
# is the step that gave `stable` an APK to point at.
|
||||||
case "${{ github.ref_name }}" in
|
case "${{ github.ref_name }}" in
|
||||||
main|v*) channel=stable ;;
|
main) channel=stable ;;
|
||||||
*) channel=dev ;;
|
*) channel=dev ;;
|
||||||
esac
|
esac
|
||||||
echo "Baking in the $channel client."
|
echo "Baking in the $channel client."
|
||||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
||||||
|
|||||||
@@ -16,35 +16,16 @@ name: Desktop (Tauri)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
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).
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# silently fail to run.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
paths:
|
|
||||||
- "desktop/**"
|
|
||||||
# The shared client core (store + sync engine) the desktop wraps. Its own
|
|
||||||
# crate since the Android client binds the same code, so a change there is a
|
|
||||||
# change to this app even though nothing under desktop/ moved.
|
|
||||||
- "core/**"
|
|
||||||
# The Android uniffi shim. It builds no desktop artifact, but it is a
|
|
||||||
# workspace member, so this lane's `cargo clippy --all-targets` is what
|
|
||||||
# compiles and lints it — and until the Android lane exists (M12 step 5),
|
|
||||||
# it is the ONLY thing that does.
|
|
||||||
- "android/**"
|
|
||||||
# The workspace manifest and lockfile, which now live at the repo root.
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
|
|
||||||
# the desktop binary, so any part of it changing means the shipped app is out
|
|
||||||
# of date. Config and lockfile included — a dependency bump changes the bundle
|
|
||||||
# as surely as a component does.
|
|
||||||
- "frontend/**"
|
|
||||||
# The version deriver. It decides what this artifact CLAIMS to be, so a change
|
|
||||||
# to it is a change to the artifact — and `packaging/version.sh` lists this
|
|
||||||
# same set from the other side. Two places holding one decision, which is why
|
|
||||||
# step 6 replaces these filters with skip-if-exists. Until then: edit one, edit
|
|
||||||
# the other. Learned the direct way — 85ead4d changed the deriver and this lane
|
|
||||||
# did not run at all.
|
|
||||||
- "packaging/**"
|
|
||||||
- ".forgejo/workflows/desktop.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -58,9 +39,56 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
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.
|
||||||
|
#
|
||||||
|
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
|
||||||
|
# Skipping because "the channel already serves this version" is indistinguishable
|
||||||
|
# from "we derived a stale value that happens to match" unless something checks.
|
||||||
|
decide:
|
||||||
|
name: Build, or is the channel already serving this?
|
||||||
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.d.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Derives a version — depth-1 is silently wrong (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: d
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# A tag has no channel to compare against and still builds bundles until
|
||||||
|
# step 7 retires that lane. Always build, never skip.
|
||||||
|
case "${{ github.ref }}" in
|
||||||
|
refs/tags/*)
|
||||||
|
echo "Tag build — no channel to compare against."
|
||||||
|
echo "build=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/guard-forward.sh desktop "$channel"
|
||||||
|
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Tauri desktop (Linux)
|
name: Tauri desktop (Linux)
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
needs: [decide]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
@@ -126,26 +154,6 @@ jobs:
|
|||||||
# so making it conditional is what lets the pipeline stay green before the
|
# 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
|
# operator has added the 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.
|
||||||
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
|
|
||||||
# lane derives a number and trusts it; this compares the derived value against
|
|
||||||
# what the channel is actually serving, and fails the lane if it went DOWN.
|
|
||||||
#
|
|
||||||
# Placed before the build, not after: a bad derivation should cost seconds, not
|
|
||||||
# a five-minute compile and a publish that has to be undone. Too-low is the
|
|
||||||
# unrecoverable direction — every installed client reports "up to date" forever
|
|
||||||
# and no later build fixes it until one climbs back above the bad number
|
|
||||||
# (#2183, #2993).
|
|
||||||
- name: Guard — the version must not go backwards
|
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${ github.token }
|
|
||||||
run: |
|
|
||||||
case "$GITHUB_REF_NAME" in
|
|
||||||
main) channel=stable ;;
|
|
||||||
*) channel=dev ;;
|
|
||||||
esac
|
|
||||||
sh packaging/guard-forward.sh desktop "$channel"
|
|
||||||
|
|
||||||
- name: Tauri build (deb + AppImage)
|
- name: Tauri build (deb + AppImage)
|
||||||
env:
|
env:
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
@@ -306,7 +314,8 @@ jobs:
|
|||||||
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
||||||
windows:
|
windows:
|
||||||
name: Windows installer (cross-compiled)
|
name: Windows installer (cross-compiled)
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
needs: [decide]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
||||||
@@ -348,26 +357,6 @@ jobs:
|
|||||||
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
|
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
|
||||||
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
|
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
|
||||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||||
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
|
|
||||||
# lane derives a number and trusts it; this compares the derived value against
|
|
||||||
# what the channel is actually serving, and fails the lane if it went DOWN.
|
|
||||||
#
|
|
||||||
# Placed before the build, not after: a bad derivation should cost seconds, not
|
|
||||||
# a five-minute compile and a publish that has to be undone. Too-low is the
|
|
||||||
# unrecoverable direction — every installed client reports "up to date" forever
|
|
||||||
# and no later build fixes it until one climbs back above the bad number
|
|
||||||
# (#2183, #2993).
|
|
||||||
- name: Guard — the version must not go backwards
|
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${ github.token }
|
|
||||||
run: |
|
|
||||||
case "$GITHUB_REF_NAME" in
|
|
||||||
main) channel=stable ;;
|
|
||||||
*) channel=dev ;;
|
|
||||||
esac
|
|
||||||
sh packaging/guard-forward.sh desktop "$channel"
|
|
||||||
|
|
||||||
- name: Tauri build (NSIS installer)
|
- name: Tauri build (NSIS installer)
|
||||||
env:
|
env:
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
|||||||
+71
-17
@@ -3,7 +3,8 @@
|
|||||||
# Refuse to publish a version lower than the one already on the channel.
|
# Refuse to publish a version lower than the one already on the channel.
|
||||||
#
|
#
|
||||||
# guard-forward.sh <desktop|android> <dev|stable>
|
# guard-forward.sh <desktop|android> <dev|stable>
|
||||||
# guard-forward.sh compare <a> <b> exit 0 iff a sorts strictly 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
|
||||||
#
|
#
|
||||||
# Note 3127 §6.3. Everything else in this milestone derives a number and trusts it;
|
# Note 3127 §6.3. Everything else in this milestone derives a number and trusts it;
|
||||||
# this is the one thing that checks the answer against reality before a user gets it.
|
# this is the one thing that checks the answer against reality before a user gets it.
|
||||||
@@ -53,6 +54,64 @@ version_lt() {
|
|||||||
return 1 # equal
|
return 1 # equal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auth if we have it, anonymous if not — the releases are public, but a token costs
|
||||||
|
# nothing and keeps this working if that ever changes.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# empty channel and catastrophic for a runner image without curl, where it would
|
||||||
|
# silently turn the guard into a no-op that reports success on every build.
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "guard-forward.sh: curl is not on PATH — refusing to run, because every" >&2
|
||||||
|
echo " lookup here would read as 'nothing published' and this" >&2
|
||||||
|
echo " guard would pass without checking anything." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fetch() {
|
||||||
|
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||||
|
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "$1" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
curl -fsSL "$1" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# What the channel is serving, per artifact. ONE definition of where to look, shared
|
||||||
|
# with `should-build.sh` — the skip decision and the guard must agree about what is
|
||||||
|
# published, and two readers of one fact is how this repo keeps producing #2181-2183.
|
||||||
|
published_for() {
|
||||||
|
case "$1" in
|
||||||
|
desktop)
|
||||||
|
# What the UPDATER reads. The manifest is the thing that decides whether a
|
||||||
|
# client is offered a build, so it is the authority on what is published.
|
||||||
|
fetch "$SERVER/$REPO/releases/download/$2/latest.json" \
|
||||||
|
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
|
||||||
|
| sed -E 's/.*"([^"]+)"$/\1/'
|
||||||
|
;;
|
||||||
|
android)
|
||||||
|
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
|
||||||
|
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
|
||||||
|
| grep -oE '[0-9]+$'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The NAME the channel serves, which is the commit-derived value. Separate from
|
||||||
|
# `published_for` because the guard compares ordering KEYS and the skip decision
|
||||||
|
# compares identity — for Android those are different fields, and conflating them
|
||||||
|
# would make every build look like a change (the code is build-time; it always moves).
|
||||||
|
published_name() {
|
||||||
|
case "$1" in
|
||||||
|
desktop) published_for desktop "$2" ;;
|
||||||
|
android)
|
||||||
|
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
|
||||||
|
| grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
|
||||||
|
| sed -E 's/.*"([^"]+)"$/\1/'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# An explicit comparison mode, so the ordering logic is testable without a network
|
# An explicit comparison mode, so the ordering logic is testable without a network
|
||||||
# and inspectable without a push. Read-only and bypasses nothing — it is the same
|
# and inspectable without a push. Read-only and bypasses nothing — it is the same
|
||||||
# function the guard itself uses, which is the point: a test of a reimplementation
|
# function the guard itself uses, which is the point: a test of a reimplementation
|
||||||
@@ -63,6 +122,13 @@ if [ "$artifact" = "compare" ]; then
|
|||||||
if version_lt "$a" "$b"; then exit 0; else exit 1; fi
|
if version_lt "$a" "$b"; then exit 0; else exit 1; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$artifact" = "published" ]; then
|
||||||
|
a2="${2:?usage: guard-forward.sh published <artifact> <channel>}"
|
||||||
|
c2="${3:?usage: guard-forward.sh published <artifact> <channel>}"
|
||||||
|
published_name "$a2" "$c2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
channel="${2:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
|
channel="${2:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
|
||||||
|
|
||||||
case "$artifact" in desktop|android) : ;; *)
|
case "$artifact" in desktop|android) : ;; *)
|
||||||
@@ -72,17 +138,9 @@ case "$channel" in dev|stable) : ;; *)
|
|||||||
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
|
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
BASE="$SERVER/$REPO/releases/download/$channel"
|
|
||||||
|
|
||||||
# Auth if we have it, anonymous if not — the releases are public, but a token costs
|
|
||||||
# nothing and keeps this working if that ever changes.
|
|
||||||
fetch() {
|
|
||||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
|
||||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "$1" 2>/dev/null || true
|
|
||||||
else
|
|
||||||
curl -fsSL "$1" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
case "$artifact" in
|
case "$artifact" in
|
||||||
@@ -90,9 +148,7 @@ case "$artifact" in
|
|||||||
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
|
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
|
||||||
# What the UPDATER reads, not what the release happens to hold — the manifest is
|
# What the UPDATER reads, not what the release happens to hold — the manifest is
|
||||||
# the thing that decides whether a client is offered this build.
|
# the thing that decides whether a client is offered this build.
|
||||||
published="$(fetch "$BASE/latest.json" \
|
published="$(published_for desktop "$channel")"
|
||||||
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
|
|
||||||
| sed -E 's/.*"([^"]+)"$/\1/')"
|
|
||||||
# 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.
|
# §6.3 says *strictly* less for exactly this reason.
|
||||||
@@ -100,9 +156,7 @@ case "$artifact" in
|
|||||||
;;
|
;;
|
||||||
android)
|
android)
|
||||||
derived="$(sh "$ROOT/packaging/version.sh" key android)"
|
derived="$(sh "$ROOT/packaging/version.sh" key android)"
|
||||||
published="$(fetch "$BASE/thoughtsync-android.json" \
|
published="$(published_for android "$channel")"
|
||||||
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
|
|
||||||
| grep -oE '[0-9]+$')"
|
|
||||||
# BUILD time, so equality is NOT ordinary — it means two builds landed in the
|
# BUILD time, so equality is NOT ordinary — it means two builds landed in the
|
||||||
# same minute, and Android refuses to install an APK whose versionCode does not
|
# same minute, and Android refuses to install an APK whose versionCode does not
|
||||||
# RISE. So this one requires strictly greater.
|
# RISE. So this one requires strictly greater.
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
#
|
||||||
|
# Does this artifact need building, or is the channel already serving this exact
|
||||||
|
# source? Prints `true` or `false`.
|
||||||
|
#
|
||||||
|
# should-build.sh <desktop|android> <dev|stable>
|
||||||
|
#
|
||||||
|
# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by
|
||||||
|
# VERSION and rule 145 removed exactly that. There is no `:<version>` tag to ask
|
||||||
|
# about. What there IS, for both clients, is a channel that publishes the version it
|
||||||
|
# is serving, and that answers the same question: if the channel already serves what
|
||||||
|
# this source derives, the artifact would be byte-identical and there is nothing to
|
||||||
|
# build.
|
||||||
|
#
|
||||||
|
# WHAT THIS REPLACES, and why that matters more here than the cost saving: the
|
||||||
|
# `paths:` filters in the workflows were a SECOND, independent statement of each
|
||||||
|
# artifact's file set, hand-kept beside the one in `version.sh`. They disagreed
|
||||||
|
# within a day of the sets being written — `packaging/` was added to the sets and
|
||||||
|
# not to the filters, so the commit that fixed a derivation bug never ran on the two
|
||||||
|
# lanes it fixed (85ead4d). §3 warns about exactly this duplication; one definition
|
||||||
|
# with one reader is the fix, and the cost saving is a bonus.
|
||||||
|
#
|
||||||
|
# THE SERVER IS NOT LISTED HERE, DELIBERATELY. Its image build is ~15 seconds against
|
||||||
|
# 6 and 9 minutes for the clients, so there is little to save — and always building
|
||||||
|
# it is strictly better for a server that can face the internet, because it picks up
|
||||||
|
# `python:3.12-slim` base updates on every push. That is also why the base-image
|
||||||
|
# tension in §4 does not bite this project: the artifact most exposed to it never
|
||||||
|
# skips. The clients' bases are CI runner images, pinned deliberately.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
artifact="${1:?usage: should-build.sh <desktop|android> <dev|stable>}"
|
||||||
|
channel="${2:?usage: should-build.sh <desktop|android> <dev|stable>}"
|
||||||
|
|
||||||
|
case "$artifact" in desktop|android) : ;; *)
|
||||||
|
echo "should-build.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
case "$channel" in dev|stable) : ;; *)
|
||||||
|
echo "should-build.sh: unknown channel '$channel'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The value that answers "is this the same code?" — which is not the same as the one
|
||||||
|
# the guard compares.
|
||||||
|
#
|
||||||
|
# desktop the ordering key IS the identity; one value, one clock.
|
||||||
|
# android the NAME. Its versionCode is build-time and moves every run, so
|
||||||
|
# comparing that would report a change on every push and never skip.
|
||||||
|
case "$artifact" in
|
||||||
|
desktop) derived="$(sh "$ROOT/packaging/version.sh" key desktop)" ;;
|
||||||
|
android) derived="$(sh "$ROOT/packaging/version.sh" display android)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
published="$(sh "$ROOT/packaging/guard-forward.sh" published "$artifact" "$channel")"
|
||||||
|
|
||||||
|
if [ -z "$published" ]; then
|
||||||
|
echo "should-build: $channel serves no $artifact yet — building." >&2
|
||||||
|
echo true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$derived" = "$published" ]; then
|
||||||
|
# UNCHANGED. The channel is already serving this exact source, so a build would
|
||||||
|
# produce the same artifact under the same name and republish it for nothing.
|
||||||
|
#
|
||||||
|
# Skipping is safe here in a way it would not be if anything pinned: there is no
|
||||||
|
# immutable tag to re-push with different bytes (rule 145 removed version tags),
|
||||||
|
# so the immutability argument in §4.2 does not apply and this stands on cost
|
||||||
|
# alone — which is the smaller, honest claim.
|
||||||
|
echo "should-build: $channel already serves $artifact $derived — skipping." >&2
|
||||||
|
echo false
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "should-build: $artifact moved $published -> $derived — building." >&2
|
||||||
|
echo true
|
||||||
Reference in New Issue
Block a user