Stop building a second QEMU and mirroring somebody else's kernel #108
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| pull_request: | |
| # One run per branch, not one per event. A push to a branch with an open pull request fires | |
| # both triggers; keying on `head_ref || ref_name` gives both the same string, while | |
| # `github.ref` — the obvious choice — does not (`refs/pull/N/merge` vs | |
| # `refs/heads/my-branch`, two groups, nothing de-duplicated). `cancel-in-progress` then | |
| # makes a new push supersede its predecessor, on main as well. | |
| concurrency: | |
| group: ci-${{ github.head_ref || github.ref_name }} | |
| cancel-in-progress: true | |
| # Least privilege by default: the gate reads the tree and nothing else. The jobs that | |
| # touch GitHub Packages raise it to `packages: read` themselves, at the job, so the | |
| # reason is next to the need. | |
| permissions: | |
| contents: read | |
| # --------------------------------------------------------------------------------- | |
| # Two jobs. It was five, because `task ci:full` could not run on one hosted runner: the | |
| # guest lanes needed the pinned QEMU, only executable inside the published runtime image | |
| # (ADR-0025), and a container job gets no Docker daemon for the TestContainers lanes. | |
| # Nothing in the merge gate needs QEMU now. The `gate` job below stays regardless — its | |
| # reason never depended on the split. | |
| # --------------------------------------------------------------------------------- | |
| jobs: | |
| # The merge gate, run as the one target a developer runs. It is `ci:full` and not a | |
| # list of steps for the reason the Taskfile exists: a second definition of what the gate | |
| # is, maintained in a workflow file, goes out of step with the first exactly when a lane | |
| # is added. | |
| ci: | |
| runs-on: ubuntu-latest | |
| # The gate starts a Volume Agent, and the Agent creates and inspects every qcow2 | |
| # chain by running the pinned qemu-img — v6 §7 forbids a parser of our own — so it | |
| # refuses to start without one. `task machine` fetches the pinned spin-machine release, | |
| # which is minutes of download against the tens of minutes compiling QEMU here would | |
| # cost, and this repository no longer has a QEMU to compile. | |
| permissions: | |
| contents: read | |
| packages: read | |
| # A ceiling, not a budget — these workflows have never executed: enough for the race | |
| # suite, the coverage run and the Docker lanes (e2e allows itself 15m) on a two-core | |
| # runner, and far below the six-hour default a wedged TestContainer would hold. | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| # The whole history, because `task lint` asks buf what this branch changed | |
| # about the wire contract and needs main to compare against. A shallow clone | |
| # makes that check error rather than pass, which is the right failure and | |
| # still a failure. | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up Task | |
| uses: go-task/setup-task@v2 | |
| # Every other tool (sqlc, pgschema, golangci-lint, buf and the proto plugins) is | |
| # pinned in the Taskfile and installed into ./.tools/bin by `task tools` — the same | |
| # command a developer runs locally, so CI and laptops cannot drift. | |
| - name: Cache pinned tools | |
| uses: actions/cache@v4 | |
| with: | |
| path: .tools | |
| key: tools-${{ runner.os }}-${{ hashFiles('Taskfile.yml') }} | |
| # The gate's own targets declare these as `deps`, so this step is not what makes | |
| # them present; it is what makes a toolchain download fail under its own name and | |
| # populates the cache above. | |
| - name: Install pinned tools | |
| run: task tools | |
| # A read token for the published QEMU image. The pull below is the only thing in | |
| # this job that touches GitHub Packages, which is why the permission is raised at | |
| # the job rather than at the workflow. | |
| - name: Log in to GitHub Packages | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # Named as its own step so a missing or unpublished image fails here, under a name | |
| # that says what is wrong, rather than sixteen minutes into the gate as a lane that | |
| # cannot start an Agent. | |
| - name: The pinned machine (QEMU, qemu-img, the kernel) | |
| run: task machine | |
| - name: The merge gate (task ci:full) | |
| run: task ci:full | |
| # The guest lane: the four demos, each booting a real Linux kernel against a qcow2 this | |
| # system prepared, with the real binaries as processes. | |
| # | |
| # It is a job of its own and not part of `ci:full` because it needs two artefacts this | |
| # repository does not build — the pinned QEMU and the pinned kernel — and a lane that | |
| # silently skips when they are missing is the defect this whole file is shaped against. | |
| # Here their absence is a red job with a name that says which one. | |
| # | |
| # No KVM is asked for, and this is the lane the second QEMU binary exists for: with no | |
| # /dev/kvm, hack/demo-lib.sh runs qemu-system-x86_64-tcg and emulates — slower, and the | |
| # same code path through everything this lane is actually about. The production binary | |
| # has no TCG compiled in and would refuse here, which is the point of building two. | |
| # Measured under TCG on a developer machine: stage1 in 5s, all four well under a minute; | |
| # the timeout below is a ceiling for a two-core runner, not a budget. | |
| guest: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up Task | |
| uses: go-task/setup-task@v2 | |
| - name: Cache pinned tools | |
| uses: actions/cache@v4 | |
| with: | |
| path: .tools | |
| key: tools-${{ runner.os }}-${{ hashFiles('Taskfile.yml') }} | |
| - name: Install pinned tools | |
| run: task tools | |
| - name: Log in to GitHub Packages | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # Both QEMU binaries, qemu-img and the kernel, in one step — they are one artefact. | |
| # | |
| # This used to be two steps and a paragraph about loaders: the binaries were | |
| # dynamically linked against their build image, so a bare runner could not run them | |
| # and a wrapper had to carry the loader and the library closure beside them. They are | |
| # statically linked now, so an extracted tree runs wherever the kernel does and all | |
| # of that is gone. | |
| - name: The pinned machine (QEMU, qemu-img, the kernel) | |
| run: task machine | |
| # Asserts the lane's inputs before anything boots: the kernel is the one that was | |
| # pinned, is a PVH ELF, carries the four config options a boot depends on, and the | |
| # initramfs holds a ./init. Each of those is a boot-time symptom that reads like a | |
| # backend bug when it is not named here. | |
| - name: The guest lane's inputs | |
| run: task guest:verify | |
| # The demos run the real control-plane binary against a real Postgres, which is the | |
| # point of them — the catalog is where placement, epochs and the term guard live, and | |
| # a stage that stubbed it would be asserting on a stub. `task db:dev:up` starts the | |
| # same pinned container a developer runs, so there is one definition of it rather | |
| # than a `services:` block that would be a second. | |
| - name: The pinned development Postgres | |
| run: task db:dev:up | |
| - name: Stage 1 — a guest boots off a qcow2 this system made | |
| run: task demo:stage1 | |
| - name: Stage 2 — the tip is sealed under a writing guest | |
| run: task demo:stage2 | |
| - name: Stage 3 — what the guest wrote is in the bucket, sealed | |
| run: task demo:stage3 | |
| - name: Stage 4 — the host is destroyed and the volume comes back | |
| run: task demo:stage4 | |
| - name: Stage 5 — a snapshot is a name for a commit | |
| run: task demo:stage5 | |
| - name: Stage 6 — a clone reads what its parent wrote | |
| run: task demo:stage6 | |
| # The gate. This is the check to require on the branch. | |
| # | |
| # `needs` does not do it alone: a needed job that is *skipped* leaves the dependent job | |
| # free to run, and a workflow of green-or-grey jobs is reported as passing. `if: | |
| # always()` is required for the same reason — without it this job is itself skipped the | |
| # moment anything it needs fails, and a skipped gate never says no. | |
| gate: | |
| needs: [ci, guest] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Every lane ran, and every lane passed | |
| env: | |
| # The whole `needs` context, serialized, so `needs:` above is the only list of | |
| # lanes: a hand-written line per job is a second list, and a job missing from it | |
| # is a lane the gate reports green without checking. jq is preinstalled on hosted | |
| # Ubuntu runners and this is not a container job; if it ever is not, `set -e` | |
| # makes that a red gate. | |
| NEEDS: ${{ toJSON(needs) }} | |
| run: | | |
| set -euo pipefail | |
| results=$(jq -r 'to_entries[] | "\(.key)=\(.value.result)"' <<< "$NEEDS") | |
| # An enumeration that came back empty is the no-op that reports success — the | |
| # shape of every defect this gate was built to remove (a `-run` regex matching | |
| # no tests, a lane skipping itself, a target exiting 0 after printing SKIP). | |
| # A gate with no lanes has not passed; it has failed to run. | |
| if [ -z "$results" ]; then | |
| echo "::error::the gate enumerated no lanes — it would be green having checked nothing" | |
| exit 1 | |
| fi | |
| failed=0 | |
| { | |
| echo "## Gate" | |
| echo | |
| echo "| lane | result |" | |
| echo "| --- | --- |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| while IFS='=' read -r job result; do | |
| [ -n "$job" ] || continue | |
| echo "| \`$job\` | $result |" >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$result" != "success" ]; then | |
| failed=1 | |
| # "skipped" is the case worth spelling out: it is not a neutral outcome | |
| # here, it is a proof this repository claims to make and did not. | |
| echo "::error::$job did not pass (${result:-no result}) — the gate is not green" | |
| fi | |
| done <<< "$results" | |
| if [ "$failed" -ne 0 ]; then | |
| echo >> "$GITHUB_STEP_SUMMARY" | |
| echo "A lane that was **skipped** counts as a failure: a run that did not make a" >> "$GITHUB_STEP_SUMMARY" | |
| echo "proof this repository claims has not passed the gate, it has failed to reach it." >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| echo "every lane ran and passed" |