Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -19,18 +19,9 @@ name: Android
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
concurrency:
|
||||
@@ -45,8 +36,46 @@ env:
|
||||
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`.
|
||||
#
|
||||
# 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:
|
||||
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
|
||||
# proven-working label that can pull our container images.
|
||||
runs-on: flutter-ci
|
||||
@@ -126,26 +155,6 @@ jobs:
|
||||
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
|
||||
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
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
|
||||
+35
-36
@@ -1,12 +1,21 @@
|
||||
# CI runs first; build only proceeds if lint + typecheck pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||
# Push to dev: typecheck + lint + test + build :dev
|
||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||
#
|
||||
# main is the production line, so a merge to main rebuilds and moves :latest to its
|
||||
# tip (family rule 46) — no version release required. The :<sha> image is the
|
||||
# immutable rollback unit for every build.
|
||||
# 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.
|
||||
#
|
||||
# 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):
|
||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||
@@ -17,7 +26,6 @@ name: CI & Build
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "frontend/**"
|
||||
@@ -34,11 +42,10 @@ on:
|
||||
# `gate` job below for the other half.
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
|
||||
# own group implicitly and are never cancelled.
|
||||
# Cancel older runs on the same branch when a newer push lands.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -66,7 +73,7 @@ jobs:
|
||||
# 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' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -91,17 +98,6 @@ jobs:
|
||||
exit 0
|
||||
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
|
||||
# compare, so build rather than stall.
|
||||
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
||||
@@ -127,7 +123,12 @@ jobs:
|
||||
echo "Changed in this push:"
|
||||
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 "This push also changes the Android client. Standing down: the"
|
||||
echo "Android lane will publish a new APK and dispatch this workflow,"
|
||||
@@ -142,7 +143,7 @@ jobs:
|
||||
|
||||
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
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -159,7 +160,7 @@ jobs:
|
||||
|
||||
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
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -172,7 +173,7 @@ jobs:
|
||||
|
||||
test:
|
||||
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
|
||||
container:
|
||||
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
|
||||
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
||||
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
|
||||
container:
|
||||
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`,
|
||||
# NOT bash `[[ ]]`.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
# 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
|
||||
@@ -303,15 +303,14 @@ jobs:
|
||||
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
TAGS="${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/heads/main)
|
||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
||||
# the :<sha> above is the rollback unit.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
|
||||
;;
|
||||
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
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
@@ -349,8 +348,8 @@ jobs:
|
||||
# from it. Not a versioning gap; a plain defect, fixed here because this
|
||||
# is the step that gave `stable` an APK to point at.
|
||||
case "${{ github.ref_name }}" in
|
||||
main|v*) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
echo "Baking in the $channel client."
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
||||
|
||||
@@ -16,35 +16,16 @@ 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).
|
||||
#
|
||||
# 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]
|
||||
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:
|
||||
|
||||
concurrency:
|
||||
@@ -58,9 +39,56 @@ permissions:
|
||||
contents: write
|
||||
|
||||
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:
|
||||
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
|
||||
container:
|
||||
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
|
||||
# operator has added the secret. With the key present, each bundle gets a
|
||||
# `.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)
|
||||
env:
|
||||
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.
|
||||
windows:
|
||||
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
|
||||
container:
|
||||
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
|
||||
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
|
||||
# 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)
|
||||
env:
|
||||
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.
|
||||
#
|
||||
# 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;
|
||||
# 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
|
||||
}
|
||||
|
||||
# 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
|
||||
# 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
|
||||
@@ -63,6 +122,13 @@ if [ "$artifact" = "compare" ]; then
|
||||
if version_lt "$a" "$b"; then exit 0; else exit 1; 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>}"
|
||||
|
||||
case "$artifact" in desktop|android) : ;; *)
|
||||
@@ -72,17 +138,9 @@ case "$channel" in dev|stable) : ;; *)
|
||||
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
|
||||
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
|
||||
@@ -90,9 +148,7 @@ case "$artifact" in
|
||||
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
|
||||
# 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.
|
||||
published="$(fetch "$BASE/latest.json" \
|
||||
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
|
||||
| sed -E 's/.*"([^"]+)"$/\1/')"
|
||||
published="$(published_for desktop "$channel")"
|
||||
# COMMIT time, so EQUALITY IS THE ORDINARY CASE: an unchanged source derives
|
||||
# exactly what it derived last time, and `<=` would fail every no-change build.
|
||||
# §6.3 says *strictly* less for exactly this reason.
|
||||
@@ -100,9 +156,7 @@ case "$artifact" in
|
||||
;;
|
||||
android)
|
||||
derived="$(sh "$ROOT/packaging/version.sh" key android)"
|
||||
published="$(fetch "$BASE/thoughtsync-android.json" \
|
||||
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
|
||||
| grep -oE '[0-9]+$')"
|
||||
published="$(published_for android "$channel")"
|
||||
# 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
|
||||
# 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