diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index 0788f0b..eb64b38 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 6cd7810..54f08ce 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,12 +1,21 @@ # CI runs first; build only proceeds if lint + typecheck pass. # -# Push to dev: typecheck + lint + test + build :dev + : -# Push to main: typecheck + lint + test + build :latest + : -# Tag v* (release): typecheck + lint + test + build :latest + : + : +# Push to dev: typecheck + lint + test + build :dev +# Push to main: typecheck + lint + test + build :latest + : # -# 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 : 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 : 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. : 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 : 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 : 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" diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 9fc6959..c79c6e0 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -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 }} diff --git a/packaging/guard-forward.sh b/packaging/guard-forward.sh index 3328bd3..70cfae7 100755 --- a/packaging/guard-forward.sh +++ b/packaging/guard-forward.sh @@ -3,7 +3,8 @@ # Refuse to publish a version lower than the one already on the channel. # # guard-forward.sh -# guard-forward.sh compare exit 0 iff a sorts strictly below b +# guard-forward.sh compare exit 0 iff a sorts below b +# guard-forward.sh published 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 }" + c2="${3:?usage: guard-forward.sh published }" + published_name "$a2" "$c2" + exit 0 +fi + channel="${2:?usage: guard-forward.sh }" 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. diff --git a/packaging/should-build.sh b/packaging/should-build.sh new file mode 100755 index 0000000..1136bd3 --- /dev/null +++ b/packaging/should-build.sh @@ -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 +# +# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by +# VERSION and rule 145 removed exactly that. There is no `:` 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 }" +channel="${2:?usage: should-build.sh }" + +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