From ce88c4ebf50588d1f0cd8058245780159e68e42b Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Mon, 2 Nov 2026 19:05:07 -0500 Subject: [PATCH 1/4] Release: promote main's CI and Mobile runs instead of racing them The tag push and main's push of the same commit start together, so the old lookup for a successful CI run always came up empty and every platform recompiled. Release also published commits whose main CI failed, and Mobile rebuilt the phones on the tag only to time out waiting for the release. A wait-for-ci job now finds main's CI run for the commit, waits for it, and hands its id to the build jobs; a failed run fails the release, and no run means compiling as before. A phones job does the same with the Mobile run (starting one on the ref when main never ran it) and adds the APK and the unsigned iOS app to the release. Mobile no longer runs on tags. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mobile.yml | 46 -------------------- .github/workflows/release.yml | 79 +++++++++++++++++++++++++++++++--- packaging/ci/reuse-binaries.sh | 16 +++---- packaging/ci/wait-for-run.sh | 76 ++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 61 deletions(-) create mode 100644 packaging/ci/wait-for-run.sh diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6cfb5962..9f394bfc 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -12,8 +12,6 @@ name: Mobile on: push: branches: [main] - # A release tag also builds the phones, so their files can go on the release (see attach-to-release). - tags: ["v*"] paths: - "crates/tinyclient/**" - "crates/protocol/**" @@ -190,47 +188,3 @@ jobs: name: tinyclient-ios-unsigned path: mobile/ios/build/Tiny.xcarchive/Products/Applications/Tiny.app if-no-files-found: error - - # On a release tag, put the phone builds on the GitHub release next to the desktop installers. - # The release is created by release.yml in parallel, so this waits for it to exist. - # The Android APK is a debug build; the iOS app is unsigned and only runs once re-signed. - attach-to-release: - if: startsWith(github.ref, 'refs/tags/v') - needs: [android-app, ios-app] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v7 - with: - name: tinyclient-android-debug - path: phone - - uses: actions/download-artifact@v7 - with: - name: tinyclient-ios-unsigned - path: phone/Tiny.app - - name: Name and zip the files - env: - VERSION: ${{ github.ref_name }} - working-directory: phone - run: | - mv app-debug.apk "tinyclient-$VERSION-android-debug.apk" - zip -qr "tinyclient-$VERSION-ios-unsigned.zip" Tiny.app - rm -rf Tiny.app - ls -l - - name: Wait for the release, then attach - env: - GH_TOKEN: ${{ github.token }} - # No checkout in this job, so gh needs the repo named. - GH_REPO: ${{ github.repository }} - VERSION: ${{ github.ref_name }} - run: | - for attempt in $(seq 1 60); do - if gh release view "$VERSION" >/dev/null 2>&1; then - gh release upload "$VERSION" phone/* --clobber - exit 0 - fi - sleep 30 - done - echo "release $VERSION never appeared" >&2 - exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be8e8b74..7bb94ef2 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,17 @@ name: Release # same files without publishing (`gh workflow run release.yml --ref BRANCH`); they are kept as the # run's `release-all` artifact. # -# macOS, Windows, and Linux x86_64 skip compilation when CI already built that commit. The -# programs are packaged and signed here. Linux aarch64 is built here; CI does not cover it. +# A release is promoted from the commit's run on main rather than built from scratch. The tag +# and the main push of a commit start together, so the first job finds the CI run (ci.yml) that +# main's push started and waits for it. If that run fails, nothing is published. If it passes, +# each platform takes its programs from that run's packages and only packages and signs them +# here; a platform whose package the run lacks compiles as before. A commit main's CI never +# looked at (only docs changed, or a branch run by hand) has no run to wait for, and every +# platform compiles. +# +# The phones come from the Mobile run (mobile.yml) of the same commit in the same way. Mobile +# only runs on main when the app's code changed, so when there is no such run this workflow +# starts one on the tag and waits for it. A phone build that fails fails the release. on: push: tags: @@ -42,8 +51,58 @@ jobs: RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/v${{ inputs.announce }} run: packaging/release-note.sh + # One job waits for CI, so the four build jobs do not each poll for it. + wait-for-ci: + if: github.event_name == 'push' || inputs.announce == '' + runs-on: ubuntu-latest + timeout-minutes: 90 + outputs: + run: ${{ steps.ci.outputs.run }} + steps: + - uses: actions/checkout@v5 + - name: Wait for main's CI run of this commit + id: ci + env: + GH_TOKEN: ${{ github.token }} + run: packaging/ci/wait-for-run.sh ci.yml + + # The APK is a debug build; the iOS app is unsigned and only runs once re-signed. + phones: + if: github.event_name == 'push' || inputs.announce == '' + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + # To start the Mobile workflow when main never ran it for this commit. + actions: write + steps: + - uses: actions/checkout@v5 + - name: Name the files + run: packaging/ci-label.sh >> "$GITHUB_ENV" + - name: Wait for the Mobile run of this commit + id: mobile + env: + GH_TOKEN: ${{ github.token }} + run: packaging/ci/wait-for-run.sh mobile.yml --dispatch + - name: Take, name and zip the files + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ steps.mobile.outputs.run }} + run: | + gh run download "$RUN" -n tinyclient-android-debug -D phone + gh run download "$RUN" -n tinyclient-ios-unsigned -D phone/Tiny.app + mkdir -p dist + mv phone/app-debug.apk "dist/tinyclient-$LABEL-android-debug.apk" + (cd phone && zip -qr "../dist/tinyclient-$LABEL-ios-unsigned.zip" Tiny.app) + ls -l dist + - uses: actions/upload-artifact@v6 + with: + name: release-phones + path: dist/* + if-no-files-found: error macos: + needs: wait-for-ci runs-on: macos-latest steps: - uses: actions/checkout@v5 @@ -53,7 +112,8 @@ jobs: id: reuse env: GH_TOKEN: ${{ github.token }} - run: packaging/ci/reuse-binaries.sh macos + CI_RUN: ${{ needs.wait-for-ci.outputs.run }} + run: packaging/ci/reuse-binaries.sh macos "$CI_RUN" - uses: ./.github/actions/setup-rust if: steps.reuse.outputs.found != 'true' with: @@ -95,6 +155,7 @@ jobs: path: dist/* linux: + needs: wait-for-ci strategy: fail-fast: false matrix: @@ -115,7 +176,8 @@ jobs: id: reuse env: GH_TOKEN: ${{ github.token }} - run: packaging/ci/reuse-binaries.sh ${{ matrix.artifact }} + CI_RUN: ${{ needs.wait-for-ci.outputs.run }} + run: packaging/ci/reuse-binaries.sh ${{ matrix.artifact }} "$CI_RUN" # Packaging reads the linked libraries even when the programs themselves came from CI. - name: Install packaging dependencies if: steps.reuse.outputs.found == 'true' @@ -195,6 +257,7 @@ jobs: run: docker run --rm -v "$PWD:/w" -w /w -e LABEL -e VERSION -e ARCH=${{ matrix.arch }} ${{ matrix.image }} packaging/linux/smoke.sh ${{ matrix.kind }} windows: + needs: wait-for-ci runs-on: windows-latest steps: - uses: actions/checkout@v5 @@ -206,7 +269,8 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - run: packaging/ci/reuse-binaries.sh windows + CI_RUN: ${{ needs.wait-for-ci.outputs.run }} + run: packaging/ci/reuse-binaries.sh windows "$CI_RUN" - uses: ./.github/actions/setup-rust if: steps.reuse.outputs.found != 'true' with: @@ -238,7 +302,7 @@ jobs: path: dist/* publish: - needs: [macos, linux, linux-smoke, windows] + needs: [macos, linux, linux-smoke, windows, phones] runs-on: ubuntu-latest permissions: contents: write @@ -253,7 +317,8 @@ jobs: merge-multiple: true # Each package also goes up without the version in its name, so a fixed address # (releases/latest/download/tinyclient-macos.dmg, which tinyclient.io/download/macos points - # at) always serves the newest one. SHA256SUMS covers both names. + # at) always serves the newest one. The phone files get one too, such as + # tinyclient-android-debug.apk. SHA256SUMS covers both names. - name: Unversioned copies and checksums working-directory: dist run: | diff --git a/packaging/ci/reuse-binaries.sh b/packaging/ci/reuse-binaries.sh index 0f60e77c..b034e5d9 100755 --- a/packaging/ci/reuse-binaries.sh +++ b/packaging/ci/reuse-binaries.sh @@ -1,13 +1,16 @@ #!/bin/sh -# Copies the release programs out of the packages CI already uploaded for this commit. +# Copies the release programs out of the packages a CI run already uploaded for this commit. +# The run is the one wait-for-run.sh found and waited for; release.yml passes its id. # found=true means target/release holds tinyclient, chatd, mediad and tinyclient-login. -# A commit CI did not build (or whose CI failed) leaves found=false, and the caller compiles. +# With no run id, or a run without this platform's package, found=false and the caller compiles. set -eu -kind=${1:?usage: reuse-binaries.sh macos|linux-x86_64|windows} +kind=${1:?usage: reuse-binaries.sh macos|linux-x86_64|linux-aarch64|windows [CI run id]} +run=${2:-} case "$kind" in macos) artifact=tinyclient-macos ;; linux-x86_64) artifact=tinyclient-linux-x86_64 ;; + linux-aarch64) artifact=tinyclient-linux-aarch64 ;; windows) artifact=tinyclient-windows-x86_64 ;; *) echo "reuse-binaries.sh: nothing to reuse for $kind" @@ -31,7 +34,7 @@ take() { chmod +x "target/release/$program" done ;; - linux-x86_64) + linux-x86_64 | linux-aarch64) archive=$(find "$stage" -name 'tinyclient-*-linux-*.tar.gz' | head -1) [ -n "$archive" ] || return 1 unpack=$(mktemp -d) @@ -64,9 +67,6 @@ take() { } found=false -run=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --status success --limit 1 \ - --json databaseId --jq '.[0].databaseId // empty' || true) - if [ -n "$run" ]; then stage=$(mktemp -d) if gh run download "$run" -n "$artifact" -D "$stage" && take "$stage"; then @@ -77,7 +77,7 @@ if [ -n "$run" ]; then fi rm -rf "$stage" else - echo "reuse-binaries.sh: no successful CI run for $GITHUB_SHA; compiling" + echo "reuse-binaries.sh: no CI run for $GITHUB_SHA to reuse; compiling" fi echo "found=$found" >> "${GITHUB_OUTPUT:?reuse-binaries.sh is for GitHub Actions}" diff --git a/packaging/ci/wait-for-run.sh b/packaging/ci/wait-for-run.sh new file mode 100644 index 00000000..32fbabc0 --- /dev/null +++ b/packaging/ci/wait-for-run.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# For the release workflow: finds the run of another workflow (ci.yml or mobile.yml) that main's +# push started for this commit, waits for it to finish, and prints its id as run=. +# A tag and the main push of the same commit start together, so on a tag the search keeps trying +# for a few minutes before it decides there is no such run. Run by hand on a branch, it looks once. +# A run that fails fails this script, so nothing is released from a commit main's CI rejected. +# With no run to wait for, run= is empty and the caller builds the files itself; with --dispatch +# this script starts the workflow on the ref instead and waits for that run. +set -eu + +workflow=${1:?usage: wait-for-run.sh ci.yml|mobile.yml [--dispatch]} +dispatch=${2:-} +sha=${GITHUB_SHA:?wait-for-run.sh is for GitHub Actions} +out=${GITHUB_OUTPUT:?wait-for-run.sh is for GitHub Actions} + +if [ "${GITHUB_REF_TYPE:-}" = tag ]; then + attempts=10 +else + attempts=1 +fi + +# Prints the id of the newest run matching the arguments, or nothing. +newest() { + gh run list --workflow "$workflow" --commit "$sha" --limit 1 "$@" \ + --json databaseId --jq '.[0].databaseId // empty' +} + +run= +attempt=1 +while :; do + run=$(newest --event push --branch main) + [ -n "$run" ] && break + [ "$attempt" -ge "$attempts" ] && break + echo "wait-for-run.sh: no $workflow run on main for $sha yet (attempt $attempt of $attempts)" + attempt=$((attempt + 1)) + sleep 20 +done + +if [ -z "$run" ] && [ "$dispatch" = --dispatch ]; then + # A minute of slack for the clock difference between this runner and GitHub. + since=$(date -u -d "@$(($(date +%s) - 60))" +%Y-%m-%dT%H:%M:%SZ) + echo "wait-for-run.sh: no $workflow run on main for $sha; starting one on $GITHUB_REF_NAME" + gh workflow run "$workflow" --ref "$GITHUB_REF_NAME" + for attempt in 1 2 3 4 5 6 7 8 9 10; do + sleep 10 + run=$(gh run list --workflow "$workflow" --commit "$sha" --event workflow_dispatch --limit 5 \ + --json databaseId,createdAt \ + --jq "[.[] | select(.createdAt >= \"$since\")] | .[0].databaseId // empty") + [ -n "$run" ] && break + done + if [ -z "$run" ]; then + echo "wait-for-run.sh: the $workflow run started on $GITHUB_REF_NAME never appeared" >&2 + exit 1 + fi +fi + +if [ -z "$run" ]; then + echo "wait-for-run.sh: no $workflow run on main for $sha; the release builds everything itself" + echo "run=" >> "$out" + exit 0 +fi + +url=$(gh run view "$run" --json url --jq .url) +echo "wait-for-run.sh: waiting for $workflow run $run ($url)" +# The job's timeout bounds this wait. A watch that drops out early (a network error) is resumed, +# and the conclusion is read back from the finished run rather than taken from gh's exit status. +until [ "$(gh run view "$run" --json status --jq .status)" = completed ]; do + gh run watch "$run" --interval 30 >/dev/null || sleep 30 +done +conclusion=$(gh run view "$run" --json conclusion --jq .conclusion) +if [ "$conclusion" != success ]; then + echo "wait-for-run.sh: $workflow run $run ended $conclusion; $url" >&2 + exit 1 +fi +echo "wait-for-run.sh: $workflow run $run succeeded" +echo "run=$run" >> "$out" From 9f32e8bbc9d5e02bec97265b8694e9586e930007 Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Mon, 2 Nov 2026 19:05:19 -0500 Subject: [PATCH 2/4] Release: make wait-for-run.sh executable Co-Authored-By: Claude Opus 5.5 --- packaging/ci/wait-for-run.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 packaging/ci/wait-for-run.sh diff --git a/packaging/ci/wait-for-run.sh b/packaging/ci/wait-for-run.sh old mode 100644 new mode 100755 From 169bc0a20c244e1a679cda25a0939efc0fdd787b Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Mon, 2 Nov 2026 21:37:34 -0500 Subject: [PATCH 3/4] Release: allow for queued macOS runners in the waits A dispatched Mobile run took 2h24m while macOS runners queued, past the phones job's 120 minutes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bb94ef2..432912af 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,11 +51,12 @@ jobs: RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/v${{ inputs.announce }} run: packaging/release-note.sh - # One job waits for CI, so the four build jobs do not each poll for it. + # One job waits for CI, so the four build jobs do not each poll for it. The waits are long + # because macOS runners can queue for over an hour: a dispatched Mobile run took 2h24m. wait-for-ci: if: github.event_name == 'push' || inputs.announce == '' runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 240 outputs: run: ${{ steps.ci.outputs.run }} steps: @@ -70,7 +71,7 @@ jobs: phones: if: github.event_name == 'push' || inputs.announce == '' runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 300 permissions: contents: read # To start the Mobile workflow when main never ran it for this commit. From ca0d3f81778ed895c9cd68e1fc54cc1c209ecf24 Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Mon, 2 Nov 2026 22:30:39 -0500 Subject: [PATCH 4/4] Release: look at the waited-for run once a minute Two jobs polling every 30 seconds came close to the token's hourly API allowance. Co-Authored-By: Claude Opus 5.5 --- packaging/ci/wait-for-run.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packaging/ci/wait-for-run.sh b/packaging/ci/wait-for-run.sh index 32fbabc0..99ab4b1d 100755 --- a/packaging/ci/wait-for-run.sh +++ b/packaging/ci/wait-for-run.sh @@ -62,10 +62,12 @@ fi url=$(gh run view "$run" --json url --jq .url) echo "wait-for-run.sh: waiting for $workflow run $run ($url)" -# The job's timeout bounds this wait. A watch that drops out early (a network error) is resumed, -# and the conclusion is read back from the finished run rather than taken from gh's exit status. +# The job's timeout bounds this wait, and a minute between looks keeps two waiting jobs well +# inside the token's hourly API allowance. A watch that drops out early (a network error) is +# resumed, and the conclusion is read back from the finished run rather than taken from gh's +# exit status. until [ "$(gh run view "$run" --json status --jq .status)" = completed ]; do - gh run watch "$run" --interval 30 >/dev/null || sleep 30 + gh run watch "$run" --interval 60 >/dev/null || sleep 60 done conclusion=$(gh run view "$run" --json conclusion --jq .conclusion) if [ "$conclusion" != success ]; then