From d43c8e40b9f9da495575a5cc1cca4e9d9ded12df Mon Sep 17 00:00:00 2001 From: ryanbr Date: Sat, 1 Aug 2026 19:20:13 -0700 Subject: [PATCH 1/4] app-build: build a PR's merge result without pushing a branch Probing "does this PR compile once merged?" meant creating a throwaway ci/ branch holding main+PR and dispatching at it. Every probe therefore left a "Compare & pull request" banner behind, because GitHub keys that banner off the PUSH EVENT, not the branch - so it outlives the branch the cleanup job deletes seconds later. Three probes today, three stale banners, two of them pointing at refs that already 404. GitHub already maintains the exact ref we were hand-building. refs/pull//merge IS main+PR, kept up to date by GitHub, so `pr: 931` reproduces the whole dance with no push, no branch and no banner. Blank input keeps today's behaviour - dispatch a ref, build that ref. Concurrency is keyed on the PR when one is given, so re-probing the same PR supersedes the earlier run while probes of different PRs (all dispatched from main) do not cancel each other. The cleanup job stays: it is still correct for anyone dispatching from a ci/ branch, and it simply never fires once nothing creates one. --- .github/workflows/app-build.yml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 3966eefb92..f45d4ea11f 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -25,10 +25,24 @@ on: # the iOS 26 SDK). A RELEASE pushes straight to main with no PR, but fork-release.yml builds both # Strand and NOOPiOS itself, so that path keeps its own compile gate. The gap this leaves is a # direct non-release push to main; `workflow_dispatch` below covers checking one on demand. + # `pr:` builds a PR's MERGE RESULT without pushing anything. Checking "does this PR compile once + # merged?" used to mean creating a throwaway `ci/` branch holding main+PR and dispatching at it — + # which left a "Compare & pull request" banner behind for every probe, because GitHub keys that + # banner off the PUSH EVENT and it outlives the branch the cleanup job deletes. GitHub already + # maintains exactly the ref we were hand-building, at refs/pull//merge, so no branch is needed. + # + # Leave it blank to build whatever ref you dispatched against, unchanged. workflow_dispatch: + inputs: + pr: + description: "PR number to build as merged into main (blank = build this ref as-is)" + required: false + default: "" concurrency: - group: app-build-${{ github.ref }} + # Keyed on the PR when one is given, so two probes of the SAME pr supersede each other while probes + # of different PRs (all dispatched from main) do not cancel one another. + group: app-build-${{ inputs.pr && format('pr-{0}', inputs.pr) || github.ref }} cancel-in-progress: true jobs: @@ -55,6 +69,19 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + # refs/pull//merge is GitHub's own main+PR merge commit — the same thing the old `ci/` + # branch dance produced, without a push. Blank input leaves the default checkout alone. + ref: ${{ inputs.pr && format('refs/pull/{0}/merge', inputs.pr) || '' }} + + - name: Say what is being built + run: | + if [ -n "${{ inputs.pr }}" ]; then + echo "Building PR #${{ inputs.pr }} as merged into main (refs/pull/${{ inputs.pr }}/merge)" + else + echo "Building ${GITHUB_REF_NAME} as-is" + fi + git log --oneline -1 - name: Install XcodeGen run: brew install xcodegen From 624fcb1c2e12048f013c083c86615ba414f9973a Mon Sep 17 00:00:00 2001 From: ryanbr Date: Sat, 1 Aug 2026 19:31:42 -0700 Subject: [PATCH 2/4] app-build: merge live main rather than GitHub's frozen merge ref (#1031) refs/pull//merge is frozen at the last time the PR merged CLEANLY, so for a conflicting or stale PR it still resolves - to a merge against an OLD main. Probing #931 through it would have built main as of 30 July, 30+ commits behind current, and reported SUCCESS. A false green is worse than the throwaway branch this replaces, which at least merged real main. Checks out refs/pull//head and merges origin/main in the runner instead, so the result is always against main as it is right now, and a conflicting PR fails at the merge step - before any xcodebuild - instead of compiling something nobody asked for. --- .github/workflows/app-build.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index f45d4ea11f..fd66a0ee3d 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -70,16 +70,28 @@ jobs: - name: Checkout uses: actions/checkout@v5 with: - # refs/pull//merge is GitHub's own main+PR merge commit — the same thing the old `ci/` - # branch dance produced, without a push. Blank input leaves the default checkout alone. - ref: ${{ inputs.pr && format('refs/pull/{0}/merge', inputs.pr) || '' }} + # The PR's HEAD, then merge current main below. NOT refs/pull//merge: GitHub freezes that + # ref at the last time the PR merged CLEANLY, so for a conflicting or simply stale PR it is a + # merge against an old main and it still resolves. Probing #931 through it would have compiled + # main as of 30 July, 30+ commits behind, and reported SUCCESS - a false green, and worse than + # the branch it replaced. Merging here is always against main as it is right now. + ref: ${{ inputs.pr && format('refs/pull/{0}/head', inputs.pr) || '' }} + fetch-depth: ${{ inputs.pr && 0 || 1 }} + + - name: Merge current main + if: inputs.pr != '' + run: | + git fetch --no-tags origin main + echo "PR #${{ inputs.pr }} head: $(git log --oneline -1 HEAD)" + echo "merging into: $(git log --oneline -1 FETCH_HEAD)" + git -c user.name=ci -c user.email=ci@local merge --no-edit FETCH_HEAD - name: Say what is being built run: | if [ -n "${{ inputs.pr }}" ]; then - echo "Building PR #${{ inputs.pr }} as merged into main (refs/pull/${{ inputs.pr }}/merge)" + echo "Built: PR #${{ inputs.pr }} merged into current main" else - echo "Building ${GITHUB_REF_NAME} as-is" + echo "Built: ${GITHUB_REF_NAME} as-is" fi git log --oneline -1 From ea51367be4fe62927333e7643e7dd237bd267062 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Sat, 1 Aug 2026 19:37:17 -0700 Subject: [PATCH 3/4] app-build: quote the fetch-depth values (#1031) fetch-depth: ${{ inputs.pr && 0 || 1 }} always evaluated to 1: GitHub treats the number 0 as falsy, so the && yields 0, the || sees a falsy left side and falls through to 1. The clone stayed shallow and the merge died with "refusing to merge unrelated histories" (exit 128) rather than reaching a real conflict - for every pr, mergeable or not. Caught by dispatching pr=931 and reading the log: it failed in 15s at the merge step, which looked like the intended conflict failure until the exit code and the --depth=1 in the checkout said otherwise. --- .github/workflows/app-build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index fd66a0ee3d..837ae0ee43 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -76,7 +76,10 @@ jobs: # main as of 30 July, 30+ commits behind, and reported SUCCESS - a false green, and worse than # the branch it replaced. Merging here is always against main as it is right now. ref: ${{ inputs.pr && format('refs/pull/{0}/head', inputs.pr) || '' }} - fetch-depth: ${{ inputs.pr && 0 || 1 }} + # QUOTED '0': GitHub expressions treat the number 0 as FALSY, so `inputs.pr && 0 || 1` + # silently yields 1 and leaves the clone shallow - the merge below then dies with + # "refusing to merge unrelated histories" (exit 128) for EVERY pr, mergeable or not. + fetch-depth: ${{ inputs.pr && '0' || '1' }} - name: Merge current main if: inputs.pr != '' From 6dbc6e04b9b1583f9945f662e7183b7ea8cb3a72 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Sat, 1 Aug 2026 19:39:47 -0700 Subject: [PATCH 4/4] app-build: drop the ci/-branch cleanup job (#1031) It existed to delete the disposable ci/ branches the old probe pattern pushed, so the Compare & pull request banner would not linger. Two problems: the banner is keyed on the PUSH EVENT and outlived the deletion anyway, which is what this PR set out to fix; and the guard is only 'ref_name starts with ci/', so it deletes ANY ci/-prefixed branch dispatched against - including one with an open PR. That is no longer hypothetical. Dispatching a test against this very branch (ci/app-build-pr-input) deleted it mid-review, and the next dispatch failed with 'No ref found'. A pull_request run skips the job, which is the only reason it had not happened sooner. With the pr: input there is no disposable branch left to clean up, so the job is both dead code and a live footgun. --- .github/workflows/app-build.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 837ae0ee43..5059dab03f 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -123,19 +123,3 @@ jobs: -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO test - - # Fork-only housekeeping: on-demand app-build runs are dispatched from disposable `ci/` branches - # (a PR's content pushed somewhere the workflow can run). Once the build finishes (pass OR fail — - # always()), delete that branch so GitHub's "Compare & pull request" banner doesn't linger after each - # run. Guarded to ci/-prefixed BRANCH refs only — a dispatch from main or any real branch is untouched. - cleanup: - needs: [build] - if: ${{ always() && github.ref_type == 'branch' && startsWith(github.ref_name, 'ci/') }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Delete the disposable CI branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${GITHUB_REF_NAME}" || true