diff --git a/.github/workflows/local-runner-smoke.yml b/.github/workflows/local-runner-smoke.yml new file mode 100644 index 0000000..5337f43 --- /dev/null +++ b/.github/workflows/local-runner-smoke.yml @@ -0,0 +1,56 @@ +name: Local runner smoke + +on: + push: + branches: [local-runner-fallback] + paths: + - .github/workflows/select-local-runner.yml + - .github/workflows/local-runner-smoke.yml + workflow_dispatch: + inputs: + force-cloud: + description: Exercise the cloud runner without stopping the Mac service + type: boolean + default: false + probe-local: + description: Owner-only installation check that requires the Mac to be online + type: boolean + default: false + +permissions: + contents: read + +jobs: + select-runner: + uses: ./.github/workflows/select-local-runner.yml + with: + force-cloud: ${{ inputs.force-cloud || false }} + secrets: + status-token: ${{ secrets.LOCAL_RUNNER_STATUS_TOKEN }} + smoke: + needs: select-runner + runs-on: ${{ inputs.probe-local && github.actor == github.repository_owner && fromJSON('["self-hosted", "macOS", "ARM64", "personal-mac"]') || fromJSON(needs.select-runner.outputs.runner) }} + timeout-minutes: 10 + steps: + - name: Check selected runner + shell: bash + env: + RUNNER_REASON: ${{ needs.select-runner.outputs.reason }} + EXPECT_LOCAL: ${{ (inputs.probe-local && github.actor == github.repository_owner) || needs.select-runner.outputs.local == 'true' }} + RUNNER_ENVIRONMENT: ${{ runner.environment }} + ACTUAL_RUNNER_NAME: ${{ runner.name }} + ACTUAL_RUNNER_OS: ${{ runner.os }} + ACTUAL_RUNNER_ARCH: ${{ runner.arch }} + run: | + echo "Selection: $RUNNER_REASON" + echo "Runner: $ACTUAL_RUNNER_NAME ($ACTUAL_RUNNER_OS/$ACTUAL_RUNNER_ARCH)" + if [ "$EXPECT_LOCAL" = "true" ]; then + test "$RUNNER_ENVIRONMENT" = "self-hosted" + test "$ACTUAL_RUNNER_OS" = "macOS" + test "$ACTUAL_RUNNER_ARCH" = "ARM64" + else + test "$RUNNER_ENVIRONMENT" = "github-hosted" + fi + printf 'Selection: %s\nRunner: %s (%s/%s)\n' \ + "$RUNNER_REASON" "$ACTUAL_RUNNER_NAME" "$ACTUAL_RUNNER_OS" "$ACTUAL_RUNNER_ARCH" \ + >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/package-tests.yml b/.github/workflows/package-tests.yml index c85bf7b..3a58c93 100644 --- a/.github/workflows/package-tests.yml +++ b/.github/workflows/package-tests.yml @@ -22,8 +22,16 @@ permissions: contents: read jobs: + select-runner: + uses: ./.github/workflows/select-local-runner.yml + with: + cloud-runner: ubuntu-latest + secrets: + status-token: ${{ secrets.LOCAL_RUNNER_STATUS_TOKEN }} + test: - runs-on: ubuntu-latest + needs: select-runner + runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }} strategy: matrix: diff --git a/.github/workflows/select-local-runner.yml b/.github/workflows/select-local-runner.yml new file mode 100644 index 0000000..a84727e --- /dev/null +++ b/.github/workflows/select-local-runner.yml @@ -0,0 +1,84 @@ +name: Select local runner + +on: + workflow_call: + inputs: + cloud-runner: + type: string + default: ubuntu-latest + force-cloud: + type: boolean + default: false + secrets: + status-token: + required: false + outputs: + runner: + value: ${{ jobs.select.outputs.runner }} + local: + value: ${{ jobs.select.outputs.local }} + reason: + value: ${{ jobs.select.outputs.reason }} + +permissions: + contents: read + +jobs: + select: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + runner: ${{ steps.runner.outputs.runner }} + local: ${{ steps.runner.outputs.local }} + reason: ${{ steps.runner.outputs.reason }} + steps: + - name: Select local Mac when online + id: runner + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + CLOUD_RUNNER: ${{ inputs.cloud-runner }} + FORCE_CLOUD: ${{ inputs.force-cloud }} + HAS_STATUS_TOKEN: ${{ secrets.status-token != '' }} + with: + github-token: ${{ secrets.status-token || github.token }} + script: | + const localLabels = ['self-hosted', 'macOS', 'ARM64', 'personal-mac']; + let labels = [process.env.CLOUD_RUNNER]; + let reason = 'cloud'; + const pr = context.payload.pull_request; + const trusted = context.actor === context.repo.owner && + (!pr || (pr.user.login === context.repo.owner && + pr.head.repo?.full_name === `${context.repo.owner}/${context.repo.repo}`)); + + if (process.env.FORCE_CLOUD === 'true') { + reason = 'forced-cloud'; + } else if (!trusted) { + reason = 'untrusted-event'; + } else if (process.env.HAS_STATUS_TOKEN !== 'true') { + reason = 'status-token-missing'; + core.warning('LOCAL_RUNNER_STATUS_TOKEN is missing; using GitHub-hosted runner.'); + } else { + try { + const runners = await github.paginate(github.rest.actions.listSelfHostedRunnersForRepo, { + ...context.repo, per_page: 100, + }); + const available = runners.some(runner => + runner.name === `personal-mac-${context.repo.repo}` && + runner.status === 'online' && + localLabels.every(label => runner.labels.some(item => item.name === label))); + if (available) { + labels = localLabels; + reason = 'local-online'; + } else { + reason = 'local-offline-or-absent'; + } + } catch (error) { + reason = 'status-api-unavailable'; + core.warning(`Runner status query failed (${error.status || 'network error'}); using GitHub-hosted runner.`); + } + } + + core.info(`Runner selection: ${reason} -> ${JSON.stringify(labels)}`); + core.setOutput('runner', JSON.stringify(labels)); + core.setOutput('local', String(reason === 'local-online')); + core.setOutput('reason', reason);