diff --git a/.github/scripts/tests/cloud-runner-routing.test.mjs b/.github/scripts/tests/cloud-runner-routing.test.mjs new file mode 100644 index 0000000000..3fd8cd3bf0 --- /dev/null +++ b/.github/scripts/tests/cloud-runner-routing.test.mjs @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8"); +// Exercise the workflow's actual boolean expression. Its string comparisons and +// boolean operators have the same results in JS for these canonical contexts. +const expression = workflow.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1]; +assert.ok(expression, "cloud routing must remain an explicit job expression"); +const timeoutExpression = workflow.match(/^ timeout-minutes: \$\{\{ (.+) \}\}$/m)?.[1]; +assert.ok(timeoutExpression, "AWS jobs must finish before the Fleet instance lifetime"); +const fleet = "runs-on/fleet=paperclip-cloud-build-x64/env=public-ci"; +const base = { repository: "paperclipai/paperclip", repository_id: "1170821064", ref: "refs/heads/master", event_name: "push" }; +for (const { name, github = {}, enabled = "true", expected = "ubuntu-latest" } of [ + { name: "canonical master push", expected: fleet }, + { name: "manual master build", github: { event_name: "workflow_dispatch" }, expected: fleet }, + { name: "disabled switch", enabled: "false" }, + { name: "missing switch", enabled: "" }, + { name: "invalid switch", enabled: "yes" }, + { name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } }, + { name: "wrong repository identity", github: { repository_id: "123" } }, + { name: "pull request", github: { event_name: "pull_request", ref: "refs/pull/123/merge" } }, + { name: "privileged PR event", github: { event_name: "pull_request_target" } }, + { name: "release tag", github: { ref: "refs/tags/v2026.911.0" } }, + { name: "branch push", github: { ref: "refs/heads/feature" } }, + { name: "manual branch build", github: { event_name: "workflow_dispatch", ref: "refs/heads/feature" } }, + { name: "workflow completion event", github: { event_name: "workflow_run" } }, +]) { + test(`cloud runner routing: ${name}`, () => { + const context = { github: { ...base, ...github }, vars: { AWS_CLOUD_BUILDS_ENABLED: enabled } }; + assert.equal(runInNewContext(expression, context), expected); + assert.equal(runInNewContext(timeoutExpression, context), expected === fleet ? 40 : 60); + }); +} diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml index 14fe69f87f..62025d3a85 100644 --- a/.github/workflows/docker-cloud.yml +++ b/.github/workflows/docker-cloud.yml @@ -6,7 +6,7 @@ on: permissions: {} -# Independent SHAs can build immediately on separate existing hosted runners. +# Independent SHAs can build immediately on separate runners. # Repeated requests for the same source serialize without cancelling a build. # No mutable canary channel is promoted here; docker.yml owns that operation. concurrency: @@ -15,8 +15,12 @@ concurrency: jobs: build-and-push-cloud: - runs-on: ubuntu-latest - timeout-minutes: 60 + # Only canonical master builds can consume the release Fleet. The runner + # group must also allow this workflow only at refs/heads/master. + # Keep an operator switch for a full-run retry on GitHub-hosted runners. + runs-on: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-cloud-build-x64/env=public-ci' || 'ubuntu-latest' }} + # Fleet instances expire after 45 minutes, including bootstrap and cleanup. + timeout-minutes: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 40 || 60 }} permissions: contents: read packages: write diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md index 6d223545b6..9465a0e5cf 100644 --- a/doc/cloud-build-readiness.md +++ b/doc/cloud-build-readiness.md @@ -122,3 +122,26 @@ registry checks can be rerun without deploying or changing mutable npm channels. When reverting this workflow, restore the master push trigger in `docker-cloud.yml` in the same change so master images continue to build. + +## AWS cloud build routing + +`AWS_CLOUD_BUILDS_ENABLED=true` routes the Docker cloud job to the +`paperclip-cloud-build-x64` RunsOn Fleet for canonical `paperclipai/paperclip` +master pushes and manual master runs. Forks, pull requests, and release tags +retain GitHub-hosted runners. The separate `AWS_CI_ENABLED` and +`AWS_CI_TRUSTED_USER_IDS` variables control PR routing. + +The cloud Fleet uses a separate runner group, `paperclip-cloud-build`, restricted +to this repository and `.github/workflows/docker-cloud.yml@refs/heads/master`. +Provision that group and Fleet before enabling the variable. The cloud runners +need at least 64 GiB free for Docker and the workspace; the initial configuration +uses 120 GiB disks with the existing 4-vCPU, 16-GiB machine size. AWS jobs have +a 40-minute workflow timeout so they finish before the 45-minute instance +lifetime; GitHub-hosted jobs retain their 60-minute timeout. Keep the registry +cache and all pushed-image verification steps enabled. + +To roll back routing, set `AWS_CLOUD_BUILDS_ENABLED=false`, then rerun the cloud +workflow. Changing the variable does not migrate an already assigned job. +Check the Actions job's runner name and runner group to verify placement. Record +queue time, image verification completion, and `Cloud deployable v1` separately; +source verification and the migrator still run on GitHub-hosted runners.