From 3f3b51708a27e546131aa92189237e3315fdac8c Mon Sep 17 00:00:00 2001 From: Sil Date: Mon, 19 Oct 2026 16:00:45 +0200 Subject: [PATCH 1/3] ci: select shared Linux runners per repository --- .github/workflows/check.yml | 2 +- .github/workflows/ready.yml | 2 +- .github/workflows/test-live.yml | 2 +- .github/workflows/test.yml | 8 ++++---- README.md | 2 ++ 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2074b18..4466f90 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,7 @@ jobs: check: name: Check if: ${{ !github.event.pull_request.draft }} - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: 15 env: pnpm_config_ignore_scripts: "true" diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index c5ea5f4..bb314bb 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -36,7 +36,7 @@ concurrency: jobs: ready: name: Ready - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: 3 steps: - name: Create merge token for repositories with release workflows diff --git a/.github/workflows/test-live.yml b/.github/workflows/test-live.yml index 9f066b6..ad911b4 100644 --- a/.github/workflows/test-live.yml +++ b/.github/workflows/test-live.yml @@ -30,7 +30,7 @@ permissions: jobs: live: name: Live - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: 30 env: pnpm_config_ignore_scripts: "true" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6397864..05f03cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,7 +47,7 @@ jobs: unit: name: Unit if: ${{ !github.event.pull_request.draft && inputs.unit }} - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: 20 env: pnpm_config_ignore_scripts: "true" @@ -71,7 +71,7 @@ jobs: preview: name: Preview if: ${{ !github.event.pull_request.draft && (inputs.http || inputs.browser) }} - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: 15 outputs: base-url: ${{ steps.preview.outputs.base-url }} @@ -231,7 +231,7 @@ jobs: name: Web needs: preview if: ${{ !github.event.pull_request.draft && needs.preview.outputs.web-enabled == 'true' }} - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: ${{ inputs.web-timeout-minutes }} # Signed-in tests can share preview accounts across browser targets and PRs. concurrency: @@ -314,7 +314,7 @@ jobs: name: Test if: ${{ always() && !github.event.pull_request.draft }} needs: [unit, preview, web] - runs-on: ${{ inputs.runner }} + runs-on: ${{ vars.CI_RUNNER || (github.event.repository.private == false && 'ubuntu-24.04') || inputs.runner }} timeout-minutes: 2 steps: - name: Require every enabled category diff --git a/README.md b/README.md index 8044b90..715bfef 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Organization-wide GitHub defaults and reusable workflows. Repositories own their Callers pin a full commit SHA from `main` after the shared workflow PR is merged. +Shared Linux jobs use GitHub's `ubuntu-24.04` for public repositories and the caller's Blacksmith runner for private repositories. To switch a project, set the repository Actions variable `CI_RUNNER` to `ubuntu-24.04` or a Blacksmith Linux runner label in GitHub's Settings → Secrets and variables → Actions → Variables. Clearing it restores the default. Commands, checks and browser coverage stay the same; native macOS jobs keep their own runners. This requires callers pinned to a revision that supports `CI_RUNNER`. + New projects take their callers from [astro-template](https://github.com/fulldotdev/astro-template) or [vite-template](https://github.com/fulldotdev/vite-template). - **Explore** experiments locally; **Implement** builds the requested change locally. From dfa01acd72f6d5ac61f282d22c8f6a667278771c Mon Sep 17 00:00:00 2001 From: Sil Date: Mon, 19 Oct 2026 16:05:55 +0200 Subject: [PATCH 2/3] ci: support isolated credentials for live login smoke --- .github/workflows/test-live.yml | 16 ++++++++++++++++ README.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-live.yml b/.github/workflows/test-live.yml index ad911b4..5f0135f 100644 --- a/.github/workflows/test-live.yml +++ b/.github/workflows/test-live.yml @@ -23,6 +23,11 @@ on: browser: type: boolean default: true + secrets: + live-test-email: + required: false + live-test-password: + required: false permissions: contents: read @@ -43,6 +48,14 @@ jobs: ref: ${{ inputs.revision }} fetch-depth: 0 persist-credentials: false + - name: Confirm the revision belongs to the default branch + env: + REVISION: ${{ inputs.revision }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + shell: bash + run: | + [[ "$REVISION" =~ ^[0-9a-f]{40}$ ]] + git merge-base --is-ancestor "$REVISION" "refs/remotes/origin/$DEFAULT_BRANCH" - uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: version: "1.1.0" @@ -130,6 +143,9 @@ jobs: run: vp run test:http - name: Safe live browser smoke if: ${{ inputs.browser }} + env: + LIVE_TEST_EMAIL: ${{ secrets.live-test-email }} + LIVE_TEST_PASSWORD: ${{ secrets.live-test-password }} # A virtual display on Linux, for repositories that run a browser headed. shell: bash run: | diff --git a/README.md b/README.md index 715bfef..4a6787c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,6 @@ Repositories with `test.yml` require `Test / Test` through the existing `Review` The Ready caller grants `actions: read` so the gate reads the current Test workflow attempt and its jobs, including reruns. -Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. Netlify requirements are managed centrally for repositories with those sites. Live URL tests run after deployment, outside the build command; signed-in tests and writes stay on previews. +Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. Netlify requirements are managed centrally for repositories with those sites. Live URL tests run after deployment, outside the build command. Product scenarios and fixture writes stay on previews. A repository may pass `live-test-email` and `live-test-password` only from its trusted main workflow for one ordinary login/read/sign-out smoke with its own limited account; browser recordings and signed-in artifacts stay off. Keep repository auto-merge disabled except for CloudCannon publishers. CloudCannon's Publish button keeps automatic publication for same-repository `preview` PRs created by `cloudcannon[bot]`, with all required checks and previews. Code PRs are merged by their agent. From 1958a9c83aa1923baa52f91ba3acd2ef97dd8ff3 Mon Sep 17 00:00:00 2001 From: Sil Date: Mon, 19 Oct 2026 16:08:51 +0200 Subject: [PATCH 3/3] test: guard production credentials against feature revisions --- tests/production.test.mjs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/production.test.mjs b/tests/production.test.mjs index 8af8664..3c49e1d 100644 --- a/tests/production.test.mjs +++ b/tests/production.test.mjs @@ -1,9 +1,39 @@ import assert from 'node:assert/strict'; -import {readFileSync} from 'node:fs'; -import {spawnSync} from 'node:child_process'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import test from 'node:test'; +test('live credentials cannot reach a feature commit or an invalid revision', () => { + const guard = readFileSync(new URL('../.github/workflows/test-live.yml', import.meta.url), 'utf8') + .split(' - name: Confirm the revision belongs to the default branch\n')[1] + .split(' run: |\n')[1].split('\n - uses:')[0] + .split('\n').map(line => line.slice(10)).join('\n'); + const cwd = mkdtempSync(join(tmpdir(), 'production-revision-')); + const git = (...args) => execFileSync('git', args, {cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).trim(); + try { + git('init', '--initial-branch=main'); + const commit = message => git('-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '--allow-empty', '-m', message); + commit('production'); + const main = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', main); + commit('unmerged feature'); + const feature = git('rev-parse', 'HEAD'); + const check = revision => spawnSync('bash', ['-e', '-o', 'pipefail', '-c', guard], { + cwd, env: {...process.env, REVISION: revision, DEFAULT_BRANCH: 'main'}, encoding: 'utf8', + }); + assert.equal(check(main).status, 0); + assert.notEqual(check(feature).status, 0); + assert.notEqual(check('HEAD').status, 0); + assert.notEqual(check('invalid; exit 0').status, 0); + } finally { + rmSync(cwd, {recursive: true, force: true}); + } +}); + const source = readFileSync(new URL('../.github/workflows/test-live.yml', import.meta.url), 'utf8') + .split(' - name: Wait for the published production revision\n')[1] .split(' run: |\n')[1].split('\n - name: Safe live HTTP smoke')[0] .split('\n').map(line => line.slice(10)).join('\n').split('deadline =')[0];