From 8051687e1eccb919f1832c8cdb6f5b66cf5aa191 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:18:58 +1000 Subject: [PATCH] CI: stop recurring false failures on main and pull requests - checks: the runner's packages.microsoft.com apt source now 403s, failing `apt-get update` and with it "Install zsh"; tolerate the refresh error (Ubuntu's lists still update and the install still fails if it can't). - e2e: a failed quiet image build is rebuilt with the full log, but the script exited 2 even when that rebuild succeeded; only fail if it fails. - ArtworkTests: a timed-out lookup gives its resolver slot back from its own thread, which a busy macOS runner may schedule after the next test starts. Wait for every slot before each test, and always return slots a test took, so one late thread can't cascade into three failures. - release: tagged builds no longer re-upload the installers as workflow artifacts (they are attached to the release; the artifact finalize timed out on v0.3.0). Pull requests build installers only when the Electron app, the macOS helper it compiles, or the workflow changes. - checks and release: a newer push to a pull request cancels the older run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/checks.yml | 13 ++++++++-- .github/workflows/release.yml | 14 +++++++++-- scripts/e2e.sh | 3 ++- tests/test_frame_android_library.py | 37 ++++++++++++++++++++++------- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f131fe4..b7624f0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# A newer push to the same pull request supersedes a run still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: checks: runs-on: ubuntu-latest @@ -17,7 +22,9 @@ jobs: with: node-version: "24" - name: Install zsh - run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + # The runner image's third-party apt sources (packages.microsoft.com) sometimes + # fail to refresh; Ubuntu's own lists still update, and the install fails if not. + run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh - name: Script syntax run: | sh -n ui/local-bin/ssh @@ -84,6 +91,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install zsh - run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + # The runner image's third-party apt sources (packages.microsoft.com) sometimes + # fail to refresh; Ubuntu's own lists still update, and the install fails if not. + run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh - name: End-to-end tests run: scripts/e2e.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04cfeaf..dcb37fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,14 +2,22 @@ name: release # Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches # the installers to that tag's GitHub release (created as a draft if missing). -# Pull requests that touch the app build the same installers as artifacts. +# Pull requests that change how the installers are built (the Electron app, the +# macOS helper it compiles, or this workflow) build the same installers as +# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and +# are tested by the checks workflow; run this one by hand to package them early. on: push: tags: ["v*"] pull_request: - paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"] + paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"] workflow_dispatch: +# A newer push to the same pull request supersedes a build still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: write @@ -49,7 +57,9 @@ jobs: tag="${GITHUB_REF_NAME}" gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" gh release upload "$tag" ${{ matrix.files }} --clobber + # Tagged builds are attached to the release above; only other builds need artifacts. - uses: actions/upload-artifact@v4 + if: ${{ !startsWith(github.ref, 'refs/tags/') }} with: name: frame-control-${{ matrix.os }} path: | diff --git a/scripts/e2e.sh b/scripts/e2e.sh index c8d4ef0..75ad349 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -23,7 +23,8 @@ started=$SECONDS print "==> Building fakeframe-frame (from $base) and fakeframe-host" # Quiet when it works; if a build fails, build again with the full log so CI shows why. -build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } } +# That second build also rides out a flaky package mirror: only its failure is fatal. +build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 } build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index cdf51c0..537e46a 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -120,7 +120,27 @@ class LauncherTests(unittest.TestCase): FIXTURES = ROOT / 'tests/fixtures/library' +def wait_for_idle_resolvers(timeout=10): + # Name lookups that outlast their deadline give their slot back from their own thread, + # which a busy runner may not schedule before the next test; wait until all are back. + from apk_sources import _images + held = [] + try: + end = time.monotonic() + timeout + while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())): + held.append(1) + if len(held) == 4: + return + raise AssertionError('artwork name lookups from an earlier test are still running') + finally: + for _ in held: + _images._resolvers.release() + + class ArtworkTests(unittest.TestCase): + def setUp(self): + wait_for_idle_resolvers() + def test_source_inputs_and_url(self): from apk_sources import _images data = (FIXTURES / 'icon.png').read_bytes() @@ -231,10 +251,7 @@ class ArtworkTests(unittest.TestCase): self.assertEqual(sum('Too many' in e for e in errors), 2) finally: gate.set() - deadline = time.monotonic() + 5 - while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False): - time.sleep(0.01) - _images._resolvers.release() # the stuck lookups finished and gave their slots back + wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back def test_resolver_slot_released_when_thread_cannot_start(self): from apk_sources import _images @@ -242,10 +259,14 @@ class ArtworkTests(unittest.TestCase): for _ in range(6): with self.assertRaises(RuntimeError): _images.get('https://example.org/a.png', deadline=time.monotonic() + 1) - for _ in range(4): # every slot came back - self.assertTrue(_images._resolvers.acquire(blocking=False)) - for _ in range(4): - _images._resolvers.release() + held = 0 + try: + while held < 4 and _images._resolvers.acquire(blocking=False): + held += 1 + self.assertEqual(held, 4) # every slot came back + finally: + for _ in range(held): # even on failure, so later tests don't inherit the leak + _images._resolvers.release() def test_deadline_covers_name_resolution(self): import threading