From f8a875d64bceff428cdcc51da4bdfd8b90577caf Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 10 Jun 2026 22:16:05 +0100 Subject: [PATCH 1/5] feat(ci): self-synced internal refs + PR pipeline self-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together remove the temp-pin-commit dance and manual test dispatches when changing the harness: 1. The composite action and test harness are now checked out at `job.workflow_sha` — the exact ref of the reusable workflow that is running — instead of a hard @main pin (action) / 'main' default (harness_ref). Workflow, action, and harness always travel together: @v2 callers get exactly v2's action, and a branch dispatch of test.yml tests the branch's in-flight changes as-is. harness_ref remains as an optional override. 2. New ci.yml runs the real prebuild -> emulator-test pipeline (android-x64, quickbit-native, incl. the floor-API emulator) on every PR touching .github/** or test-harness/**. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++ .github/workflows/prebuild-all.yml | 10 +++++++- .github/workflows/prebuild.yml | 14 ++++++++++- .github/workflows/test-android.yml | 12 ++++++---- .github/workflows/test-ios.yml | 12 ++++++---- .github/workflows/test.yml | 9 ++++--- 6 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98d20a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI (pipeline self-test) + +# Runs the real prebuild -> emulator-test pipeline on every PR that touches +# the workflows, the composite actions, or the test harness. Because the +# reusable workflows check out the action and harness at their own +# `job.workflow_sha`, the PR's in-flight changes are what actually run — +# no need for temporary action-ref pins or manual `test.yml` dispatches. +# +# Android-only on purpose: it's the cheap leg (~15 min on ubuntu runners, +# including the floor-API emulator) and where toolchain regressions like +# the RELR Android < 9 crash live. Run `test.yml` manually for an iOS leg. + +on: + pull_request: + paths: + - ".github/**" + - "test-harness/**" + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-android: + uses: ./.github/workflows/prebuild.yml + with: + # Small, fast module that still exercises the C++ stdlib include + # injection path in the prebuild action. + module_name: quickbit-native + platform: android + arch: x64 + module_version: latest + + test-android: + needs: build-android + uses: ./.github/workflows/test-android.yml + with: + module_spec: ${{ needs.build-android.outputs.module_spec }} diff --git a/.github/workflows/prebuild-all.yml b/.github/workflows/prebuild-all.yml index e3138e1..a370546 100644 --- a/.github/workflows/prebuild-all.yml +++ b/.github/workflows/prebuild-all.yml @@ -143,7 +143,15 @@ jobs: # resolves against the workspace root. - uses: actions/checkout@v6 - - uses: digidem/nodejs-mobile-bare-prebuilds/.github/actions/prebuild@main + # Same-ref harness checkout — see the comment in prebuild.yml. + - name: Checkout prebuild harness + uses: actions/checkout@v6 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .nodejs-mobile-bare-prebuilds + + - uses: ./.nodejs-mobile-bare-prebuilds/.github/actions/prebuild with: module_name: ${{ inputs.module_name }} platform: ${{ matrix.platform }} diff --git a/.github/workflows/prebuild.yml b/.github/workflows/prebuild.yml index 98ad3b4..849b973 100644 --- a/.github/workflows/prebuild.yml +++ b/.github/workflows/prebuild.yml @@ -96,7 +96,19 @@ jobs: # resolves against the workspace root. - uses: actions/checkout@v6 - - uses: digidem/nodejs-mobile-bare-prebuilds/.github/actions/prebuild@main + # Check out this repo at the SAME ref this reusable workflow is + # running from (`job.workflow_sha` resolves the caller's @v2/@main/ + # branch ref), so the composite action below always matches the + # workflow version — no @main pin that can drift from tagged + # callers, and branch dispatches test in-flight action changes. + - name: Checkout prebuild harness + uses: actions/checkout@v6 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .nodejs-mobile-bare-prebuilds + + - uses: ./.nodejs-mobile-bare-prebuilds/.github/actions/prebuild id: prebuild with: module_name: ${{ inputs.module_name }} diff --git a/.github/workflows/test-android.yml b/.github/workflows/test-android.yml index 48c7872..d86c830 100644 --- a/.github/workflows/test-android.yml +++ b/.github/workflows/test-android.yml @@ -75,9 +75,13 @@ on: default: 24 harness_ref: type: string - description: "Ref of digidem/nodejs-mobile-bare-prebuilds to pull the harness from" + description: | + Optional override for the harness checkout ref. Defaults to the + ref this reusable workflow itself is running from + (`job.workflow_sha`), so the harness always matches the workflow + version. required: false - default: "main" + default: "" git_repo_slug: type: string description: | @@ -137,8 +141,8 @@ jobs: - name: Checkout test harness uses: actions/checkout@v6 with: - repository: digidem/nodejs-mobile-bare-prebuilds - ref: ${{ inputs.harness_ref }} + repository: ${{ job.workflow_repository }} + ref: ${{ inputs.harness_ref || job.workflow_sha }} path: .nodejs-mobile-bare-prebuilds - uses: actions/setup-node@v6 diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 5c3e628..7b88fb3 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -64,9 +64,13 @@ on: default: "iPhone 15" harness_ref: type: string - description: "Ref of digidem/nodejs-mobile-bare-prebuilds to pull the harness from" + description: | + Optional override for the harness checkout ref. Defaults to the + ref this reusable workflow itself is running from + (`job.workflow_sha`), so the harness always matches the workflow + version. required: false - default: "main" + default: "" git_repo_slug: type: string description: | @@ -100,8 +104,8 @@ jobs: - name: Checkout test harness uses: actions/checkout@v6 with: - repository: digidem/nodejs-mobile-bare-prebuilds - ref: ${{ inputs.harness_ref }} + repository: ${{ job.workflow_repository }} + ref: ${{ inputs.harness_ref || job.workflow_sha }} path: .nodejs-mobile-bare-prebuilds - uses: actions/setup-node@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce95272..fa75224 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,10 @@ name: End-to-end test (manual dispatch) # whole pipeline can be exercised from the Actions tab without needing to call # these reusable workflows from another repo. # -# The harness is always checked out at the same commit as this dispatch run -# (`harness_ref: ${{ github.sha }}`), so in-flight changes to the harness or -# the composite action are tested as-is on the triggering branch. +# Everything (workflows, composite action, test harness) runs at the commit +# of this dispatch: the reusable workflows check out the action and harness +# at their own `job.workflow_sha`, so in-flight changes on the triggering +# branch are tested as-is. on: workflow_dispatch: @@ -59,7 +60,6 @@ jobs: with: module_spec: ${{ needs.build-android.outputs.module_spec }} test_script: ${{ inputs.test_script }} - harness_ref: ${{ github.sha }} build-ios: if: ${{ inputs.platforms == 'both' || inputs.platforms == 'ios' }} @@ -79,4 +79,3 @@ jobs: with: module_spec: ${{ needs.build-ios.outputs.module_spec }} test_script: ${{ inputs.test_script }} - harness_ref: ${{ github.sha }} From 7715853730208a35af4e345f3a79cd346b943f0a Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 10 Jun 2026 22:59:44 +0100 Subject: [PATCH 2/5] =?UTF-8?q?chore(ci):=20review=20nits=20=E2=80=94=20re?= =?UTF-8?q?ad-only=20permissions,=20pinned=20CI=20module=20version,=20no?= =?UTF-8?q?=20persisted=20credentials=20on=20harness=20checkouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 8 ++++++-- .github/workflows/prebuild-all.yml | 1 + .github/workflows/prebuild.yml | 1 + .github/workflows/test-android.yml | 1 + .github/workflows/test-ios.yml | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98d20a5..c494b94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ on: - ".github/**" - "test-harness/**" +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -25,11 +28,12 @@ jobs: uses: ./.github/workflows/prebuild.yml with: # Small, fast module that still exercises the C++ stdlib include - # injection path in the prebuild action. + # injection path in the prebuild action. Pinned so a breaking + # upstream release can't fail CI for unrelated PRs. module_name: quickbit-native platform: android arch: x64 - module_version: latest + module_version: "2.4.8" test-android: needs: build-android diff --git a/.github/workflows/prebuild-all.yml b/.github/workflows/prebuild-all.yml index a370546..ccda34d 100644 --- a/.github/workflows/prebuild-all.yml +++ b/.github/workflows/prebuild-all.yml @@ -150,6 +150,7 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .nodejs-mobile-bare-prebuilds + persist-credentials: false - uses: ./.nodejs-mobile-bare-prebuilds/.github/actions/prebuild with: diff --git a/.github/workflows/prebuild.yml b/.github/workflows/prebuild.yml index 849b973..f57bcf3 100644 --- a/.github/workflows/prebuild.yml +++ b/.github/workflows/prebuild.yml @@ -107,6 +107,7 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .nodejs-mobile-bare-prebuilds + persist-credentials: false - uses: ./.nodejs-mobile-bare-prebuilds/.github/actions/prebuild id: prebuild diff --git a/.github/workflows/test-android.yml b/.github/workflows/test-android.yml index d86c830..ed36e96 100644 --- a/.github/workflows/test-android.yml +++ b/.github/workflows/test-android.yml @@ -144,6 +144,7 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ inputs.harness_ref || job.workflow_sha }} path: .nodejs-mobile-bare-prebuilds + persist-credentials: false - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 7b88fb3..c816678 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -107,6 +107,7 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ inputs.harness_ref || job.workflow_sha }} path: .nodejs-mobile-bare-prebuilds + persist-credentials: false - uses: actions/setup-node@v6 with: From 4d624e1fceda44d0499a521a7605170155965c12 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 10 Jun 2026 23:20:41 +0100 Subject: [PATCH 3/5] feat: automate the floating major tag + release docs; fix dead test_script input - move-major-tag.yml force-moves vX to any pushed vX.Y.Z tag, so releasing is 'push a semver tag' and @v2 callers follow automatically. - README gains a 'Versioning and releasing' section documenting the major-tag contract, the self-synced internal refs, and the release steps (none of this was written down anywhere). - test.yml now derives test_runner=custom when test_script is set; previously a dispatched script was silently ignored (test_runner stayed 'smoke') and the run passed without executing it. Co-Authored-By: Claude Fable 5 --- .github/workflows/move-major-tag.yml | 36 +++++++++++++++++++++++ .github/workflows/test.yml | 4 +++ README.md | 43 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 .github/workflows/move-major-tag.yml diff --git a/.github/workflows/move-major-tag.yml b/.github/workflows/move-major-tag.yml new file mode 100644 index 0000000..d279f17 --- /dev/null +++ b/.github/workflows/move-major-tag.yml @@ -0,0 +1,36 @@ +name: Move major version tag + +# Keeps the floating major tag callers pin (`uses: ...@v2`) pointed at the +# latest release of that major. Pushing a semver tag (directly or via +# `gh release create`) force-moves the corresponding major tag — callers +# pick the new version up on their next run, with workflow, composite +# action, and test harness guaranteed in sync (the reusable workflows +# check themselves out at `job.workflow_sha`). + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + move-major-tag: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Force-move major tag + run: | + set -eu + major="${GITHUB_REF_NAME%%.*}" + # Peel through annotated tag objects to the commit — GITHUB_SHA + # is the tag object for annotated tags, not the commit. + commit=$(git rev-parse "${GITHUB_REF_NAME}^{commit}") + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag --force --annotate "$major" --message "Point ${major} at ${GITHUB_REF_NAME}" "$commit" + git push origin "refs/tags/${major}" --force + echo "Moved ${major} -> ${GITHUB_REF_NAME} (${commit})" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa75224..c5521bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,9 @@ jobs: uses: ./.github/workflows/test-android.yml with: module_spec: ${{ needs.build-android.outputs.module_spec }} + # test_script is only consulted when test_runner is 'custom' — + # derive it so a provided script can't be silently ignored. + test_runner: ${{ inputs.test_script != '' && 'custom' || 'smoke' }} test_script: ${{ inputs.test_script }} build-ios: @@ -78,4 +81,5 @@ jobs: uses: ./.github/workflows/test-ios.yml with: module_spec: ${{ needs.build-ios.outputs.module_spec }} + test_runner: ${{ inputs.test_script != '' && 'custom' || 'smoke' }} test_script: ${{ inputs.test_script }} diff --git a/README.md b/README.md index 069bd2a..6e62972 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ by the Holepunch team. These modules are built using - [Testing prebuilds on an emulator / simulator](#testing-prebuilds-on-an-emulator--simulator) - [Running the module's own test suite](#running-the-modules-own-test-suite) - [Patching the module before build](#patching-the-module-before-build) +- [Versioning and releasing](#versioning-and-releasing) - [Contributing](#contributing) - [License](#license) @@ -228,6 +229,48 @@ unpack and before `npm install`. To use a different directory name, pass `patches_dir:` to `prebuild-all.yml` / `prebuild.yml`. +## Versioning and releasing + +Callers pin the reusable workflows to a **floating major tag**: + +```yaml +uses: digidem/nodejs-mobile-bare-prebuilds/.github/workflows/prebuild-all.yml@v2 +``` + +Internally, nothing else is pinned: each reusable workflow checks this repo +out at its own ref (`job.workflow_sha`) and runs the composite action and +test harness from that checkout. Workflow, action, and harness therefore +always run at the same commit — whatever `v2` (or a branch, for dispatches) +resolves to. Never reference the composite action with an `@` from +inside this repo; use the local path from the harness checkout. + +### Cutting a release + +1. Merge to `main`. PRs touching `.github/**` or `test-harness/**` run the + real pipeline (`ci.yml`: prebuild → emulator test, including the + floor-API emulator), at the PR's own commit — in-flight changes are what + execute, so no manual validation dispatch is needed. +2. Tag a semver release and push it: + + ```sh + git tag v2.2.0 && git push origin v2.2.0 + # or, with release notes: + gh release create v2.2.0 --generate-notes + ``` + +3. The `move-major-tag.yml` workflow force-moves `v2` to the new tag + automatically. Callers on `@v2` pick the new version up on their next + run — no changes needed in the calling repos. + +**Breaking changes** (renamed/removed inputs, changed artifact layout, new +required permissions) get a new major: tag `v3.0.0`, which creates/moves +`v3`; existing `@v2` callers are untouched until they opt in by editing +their `uses:` references. + +To test unreleased changes end-to-end, dispatch `test.yml` from your branch +in the Actions tab — the whole pipeline (workflows, action, harness) runs at +the branch's commit. + ## Contributing We welcome contributions to this repository. If you have an idea for a new From 6fbe5c6df08e9573a903e99d185b5b022a271977 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 11 Jun 2026 03:10:13 +0100 Subject: [PATCH 4/5] fix(test-harness): bound the pidof liveness check so a wedged emulator can't hang the job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-fast pidof check had no timeout. When the emulator itself wedges (seen as 'Failed to find process owned resources for puid N', logcat goes silent), the unbounded `adb shell pidof` blocks forever — and because the SECONDS budget is only re-checked at the loop top, the loop never returns there and the job stalls to its 45-min wall clock instead of failing at TIMEOUT_SECONDS. Wrap the call in `timeout`; key the death decision off timeout's exit 124 (it alone means 'adb hung'), since pidof exits non-zero for a dead app too. A wedged emulator now falls through and is bounded by the SECONDS budget; a genuine crash still fails fast. Co-Authored-By: Claude Fable 5 --- test-harness/android/run-test.sh | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test-harness/android/run-test.sh b/test-harness/android/run-test.sh index a3ba333..a3aef53 100755 --- a/test-harness/android/run-test.sh +++ b/test-harness/android/run-test.sh @@ -44,12 +44,23 @@ while (( SECONDS < TIMEOUT_SECONDS )); do break ;; esac - elif [ -z "$(adb shell pidof -s "$APP_ID" 2>/dev/null | tr -d '[:space:]')" ]; then - # Logcat went quiet AND the app process is gone: it crashed without - # emitting the sentinel (e.g. a native SIGSEGV in an addon). Fail - # now instead of waiting out the full TIMEOUT_SECONDS. - APP_DIED=1 - break + else + # Logcat went quiet. Distinguish "app crashed" from "emulator wedged". + # `timeout` bounds the adb call so a hung emulator can't freeze this + # loop — without it the SECONDS budget (only re-checked at the loop + # top) never fires and the job stalls to its 45-min wall clock. + # `timeout` exits 124 specifically when it kills a hung adb; any other + # exit means adb answered (pidof itself exits non-zero when the + # process is absent, so we key off timeout's code, not adb's). The + # `|| rc=$?` form keeps `set -e` from exiting on that non-zero. + pid=$(timeout 15 adb shell pidof -s "$APP_ID" 2>/dev/null) && rc=0 || rc=$? + pid=${pid//[$' \t\r\n']/} + if [ "$rc" -ne 124 ] && [ -z "$pid" ]; then + # App process is gone without emitting the sentinel (e.g. a native + # SIGSEGV in an addon). Fail now instead of waiting out the timeout. + APP_DIED=1 + break + fi fi done From 38dc87e0a067d044cbc52b766c0a48e88876ccb6 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 11 Jun 2026 03:59:18 +0100 Subject: [PATCH 5/5] fix(test-harness): bound activity launch so a wedged emulator fails fast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `am start -W` blocks until the launch completes; on a flaky API 24 emulator that never returns, hanging the job at line ~20 — before the watch loop, so neither its per-read timeout nor SECONDS budget applies, and the job rode to its 45-min wall clock (seen twice on API 24). Start logcat capture before the launch, then dispatch fire-and-forget under `timeout 60` (drop -W). A stuck launch now falls through to the watch loop, which bounds the wait and reports a clean failure. Co-Authored-By: Claude Fable 5 --- test-harness/android/run-test.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test-harness/android/run-test.sh b/test-harness/android/run-test.sh index a3aef53..27b4f4f 100755 --- a/test-harness/android/run-test.sh +++ b/test-harness/android/run-test.sh @@ -15,15 +15,21 @@ adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do slee adb install -r -g "$APK" adb logcat -c -# Launch the activity. The app pumps node's stdout/stderr to logcat tag -# NODEJS-MOBILE and emits __NODE_EXIT__: when done. -adb shell am start -W -n "$APP_ID/.TestActivity" - # Run logcat as a coprocess so we can kill it explicitly once the sentinel # is seen. (A plain `adb logcat | awk` pipeline hangs because adb only # notices the pipe has closed when it next tries to write, and no further -# lines are coming once the app has exited.) +# lines are coming once the app has exited.) Started BEFORE the launch so +# no early NODEJS-MOBILE output is missed. coproc LOGCAT { adb logcat -v raw -s NODEJS-MOBILE:V; } + +# Launch the activity. The app pumps node's stdout/stderr to logcat tag +# NODEJS-MOBILE and emits __NODE_EXIT__: when done. Fire-and-forget, +# `timeout`-bounded: `am start -W` blocks until the launch completes, which +# hangs forever on a wedged emulator (seen on flaky API 24 images) — and +# that hang is BEFORE the watch loop, so neither the loop's per-read +# timeout nor its SECONDS budget can bound it. Drop -W and cap the dispatch +# so a stuck launch falls through to the loop, which fails on the timeout. +timeout 60 adb shell am start -n "$APP_ID/.TestActivity" || true # Snapshot the coproc's PID and read FD: bash UNSETS LOGCAT_PID and # LOGCAT[0] as soon as it reaps the terminated coprocess, so reading # them later can trip `set -u` (observed when the app exits quickly).