Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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