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..432912af 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,59 @@ 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. 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: 240 + 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: 300 + 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 +113,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 +156,7 @@ jobs: path: dist/* linux: + needs: wait-for-ci strategy: fail-fast: false matrix: @@ -115,7 +177,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 +258,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 +270,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 +303,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 +318,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 100755 index 00000000..99ab4b1d --- /dev/null +++ b/packaging/ci/wait-for-run.sh @@ -0,0 +1,78 @@ +#!/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, 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 60 >/dev/null || sleep 60 +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"