ci: the publish channel comes from a variable, and a fork can cut its… #1
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: | |
| branches: [ "main" ] | |
| # Every pull request, whatever its base -- note the absence of a `branches:` | |
| # filter here, which is deliberate and is the difference between this workflow | |
| # gating a change and only appearing to. | |
| # | |
| # A filter on `pull_request` matches the *base* branch, not the head. This had | |
| # `branches: [ "main" ]`, so a pull request onto any other base did not trigger | |
| # a run at all -- not a pending one, not a skipped one, none. That exempted a | |
| # whole class of pull request: a stacked chain, in which every link but the last | |
| # targets its predecessor rather than `main`, so every link but the last merged | |
| # with nothing behind it. A `push` filter is the opposite case and stays: it | |
| # matches the branch pushed to, and every branch here reaches `main` through a | |
| # pull request that is now covered. | |
| pull_request: | |
| workflow_dispatch: | |
| # One run per pull request at a time. This matters more since the filter above | |
| # came off: rebasing the bottom of a stacked chain pushes every branch in it, and | |
| # without this that is one full matrix and one e2e run per link, all of them | |
| # superseded before they finish. | |
| # | |
| # Only pull requests are cancelled. A push to `main` is the run that says whether | |
| # that commit is good, and there is no later run to replace a cancelled one with | |
| # -- which is also why pushes are grouped by commit rather than by ref: sharing a | |
| # group without cancellation makes them queue, and a third arrival cancels the | |
| # one waiting in the middle, leaving a commit on `main` with no verdict at all. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| jobs: | |
| # The Python that remains is the acceptance harness and the repo's own doc | |
| # guards, not a shipped artifact -- so there is one interpreter here, not a | |
| # matrix of five. The matrix existed because `dl` itself was a Python package | |
| # supporting >=3.10 (`workspace_state.read_clone` is the shape of that: 3.13 and | |
| # earlier raise where 3.14 returns False), and the interpreter a released `dl` | |
| # ran on stopped being a variable when the binaries took over (#267). | |
| # | |
| # `default` rather than a pinned leg: this is the environment the devcontainer | |
| # and every contributor use, so it is the one whose breakage is felt. What that | |
| # costs is worth knowing -- its Python is unpinned, so `pixi update` moves it | |
| # without anyone choosing to -- and `frozen: true` below keeps each commit | |
| # reproducible against its lockfile, making that a thing to notice at lockfile | |
| # review rather than a hole in the gate. | |
| # | |
| # `pixi run ci` compiles `rust/target/release/{dl,aid}` on the way in, because | |
| # the harness spawns them (see the `build-release` task). That is a cargo build | |
| # this job pays for and the `e2e` job pays for again, and it is deliberately not | |
| # cached here: `Swatinem/rust-cache` keys on `rustc -vV`, and these two jobs have | |
| # no rustup toolchain -- their cargo comes from the pixi environment. The | |
| # alternatives were worse. Adding `dtolnay/rust-toolchain` beside pixi's would | |
| # put two toolchains on the box and key the cache off the one that is not | |
| # building; building once in a `build` job and downloading the artifact here | |
| # would make both of these jobs wait for it, which costs more wall-clock than the | |
| # duplicate build does. Revisit if either build starts dominating the run. | |
| ci: | |
| runs-on: ubuntu-latest | |
| # A cold `pixi install` and the release build this job pays for, with the | |
| # whole pytest suite after them; it finishes in about two minutes warm. The | |
| # bound is here for the same reason it is on every other job: unbounded is six | |
| # hours, which is long enough that the run is abandoned rather than read. | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - uses: prefix-dev/setup-pixi@v0.10.2 | |
| with: | |
| cache: true | |
| frozen: true | |
| environments: default | |
| - name: CI | |
| run: | | |
| pixi run ci | |
| # blooop/devlaunch#527. A branch rebased across a release cut files its entry | |
| # inside the shipped section and git resolves it *cleanly*, so this is the | |
| # only signal there is -- `MERGEABLE` and a green suite both say nothing about | |
| # it. Pull requests only: on a push there is no base to be frozen against. | |
| # | |
| # The base commit is fetched explicitly at depth 1 rather than switching the | |
| # checkout above to `fetch-depth: 0`, which would pull the whole history into | |
| # every run of this job to serve one `git show`. | |
| - name: A released section is not a place to file a new entry | |
| if: github.event_name == 'pull_request' | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| set -euo pipefail | |
| git fetch --depth=1 origin "$BASE_SHA" | |
| # `git show` writes nothing and exits non-zero if the path is absent at | |
| # that commit, and `set -e` turns that into a failure rather than an | |
| # empty file that would compare equal to anything. | |
| git show "$BASE_SHA:CHANGELOG.md" > "$RUNNER_TEMP/base-CHANGELOG.md" | |
| python3 scripts/changelog_frozen.py \ | |
| "$RUNNER_TEMP/base-CHANGELOG.md" CHANGELOG.md | |
| # Flagged `python` since #294, so it is reported as what it is — the | |
| # harness, the doc guards and `scripts/` — rather than as "the project's | |
| # coverage". The shipped crates are the `rust` flag, uploaded by the | |
| # `rust-coverage` job. | |
| - name: Upload coverage reports to Codecov | |
| uses: codecov/codecov-action@v7 | |
| with: | |
| flags: python | |
| env: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| # The e2e suite: real devpod, real containers, on the runner's own Docker. | |
| # | |
| # Unnested on purpose. A development box needs a Docker daemon of its own | |
| # because it is shared and long-lived, and this suite deletes every workspace | |
| # it can see; `ubuntu-latest` is an ephemeral VM with Docker preinstalled, so | |
| # there is no host here to protect. No devpod install step either -- devpod is | |
| # a pixi dependency, already in the lockfile setup-pixi restores. | |
| # | |
| # Outside the matrix above, because what it exercises is devpod and Docker | |
| # rather than a Python version; one copy per matrix leg would buy nothing and | |
| # cost one image pull each. | |
| # | |
| # Per-PR rather than nightly. It costs a few minutes against a matrix that | |
| # already costs some and runs beside it, so it does not lengthen the wait by | |
| # its own duration; and a nightly failure arrives detached from the change | |
| # that caused it, on a repo where nobody is rostered to triage one. To make it | |
| # nightly instead: add `schedule:` to `on:` above, add | |
| # `if: github.event_name == 'schedule'` to this job, and drop it from `gate`'s | |
| # `needs` below -- a job that does not run is not a job that passed, so leaving | |
| # it there would block every merge. Note the condition has to be that, not | |
| # `!= 'pull_request'`: `push: branches: [main]` stays in `on:` for the matrix | |
| # job, so excluding only pull requests would leave this one running on every | |
| # merge as well as on the schedule. | |
| # | |
| # Like every job here, it runs on pushes to `main` and on every pull request | |
| # whatever its base, stacked chains included. | |
| e2e: | |
| runs-on: ubuntu-latest | |
| # A registry serving this suite's 1.25 GB fixture image at 640 B/s is not | |
| # hypothetical; it has happened. Unbounded, that is a job that hangs for six | |
| # hours and then reports a timeout nobody reads. Bounded, it is a red tick. | |
| timeout-minutes: 25 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - uses: prefix-dev/setup-pixi@v0.10.2 | |
| with: | |
| cache: true | |
| frozen: true | |
| environments: default | |
| - name: E2E | |
| run: pixi run test-e2e | |
| # The Rust suite: cargo build, the ~1400 cargo tests, clippy and fmt. | |
| # | |
| # This was `rust-parity`, the port's two-way expected-failure ratchet -- it built | |
| # the release binaries, pointed DEVLAUNCH_DL_CMD at them, ran both pytest tiers | |
| # against both implementations and failed on any failure outside | |
| # `rust/parity-manifest.txt`. All of that retired with the Python tree it was | |
| # comparing against (#267): the manifest had been empty since cutover, so what it | |
| # asserted was that two implementations agreed, and there is one now. The tiers it | |
| # drove did not retire with it -- the `ci` and `e2e` jobs above run them directly, | |
| # against the release binaries `pixi run build-release` produces. | |
| # | |
| # The tests still shell out to `python3` for their scenario builders | |
| # (`rust/dl/tests/*_scenario.py`) and for the fake devpod on PATH | |
| # (`test/fixtures/devpod_shim.py`). Both are stdlib-only and implementation-blind, | |
| # which is why this job needs no pixi environment: `ubuntu-latest` has a python3. | |
| rust: | |
| runs-on: ubuntu-latest | |
| # Sized for a Rust toolchain install and a cold `--all-targets` debug build on | |
| # an empty `rust-cache`, plus clippy over all targets. Smaller than it was: the | |
| # retired ratchet added a second build in the release profile and both pytest | |
| # tiers, real containers included, on top of everything below. Bounded at all is | |
| # the point -- unbounded, a stalled download is a job that hangs for six hours | |
| # and reports a timeout nobody reads. | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| # Kept in lockstep with rust/rust-toolchain.toml (the pin of record). | |
| toolchain: 1.97.1 | |
| components: clippy, rustfmt | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: rust | |
| - name: Build | |
| working-directory: rust | |
| run: cargo build --locked --all-targets | |
| # Split per test binary, each `timeout`-bounded, so a wedged binary fails | |
| # its own short step instead of holding the runner ~46m until it loses | |
| # contact — and the step left `in_progress` at a crash names the culprit | |
| # even when the runner dies before uploading logs. Single-threaded so the | |
| # concurrency tests do not interleave. If this is stable it becomes the | |
| # permanent shape. | |
| - name: Test devlaunch-runner | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p devlaunch-runner -- --test-threads=1 --nocapture | |
| - name: Test devlaunch-core | |
| working-directory: rust | |
| run: timeout 600 cargo test --locked -p devlaunch-core -- --test-threads=1 --nocapture | |
| # The steps here name one crate each, which is what lets each carry its own | |
| # timeout -- and what silently left this one uncovered. Its tests are the | |
| # fake devpod the integration suites use as their whole devpod, so a | |
| # fidelity gap in it is the one way a fake can do real harm. | |
| - name: Test devlaunch-test-support | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p devlaunch-test-support -- --test-threads=1 --nocapture | |
| - name: Test aid (bins + rewrite) | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p aid --bins --test rewrite -- --test-threads=1 --nocapture | |
| # Spawns and kills a real process tree (a blocking `devpod up`), so it gets | |
| # the same log-pipe fence as `dl`'s interrupt/lock_wait: output to a file, | |
| # cat after — an orphaned test child can hold the file, never this step's | |
| # log pipe, which is what the runner waits on before ending the step. | |
| - name: Test aid interrupt | |
| working-directory: rust | |
| run: | | |
| timeout -k 10 300 cargo test --locked -p aid --test interrupt -- --test-threads=1 --nocapture > aid_interrupt.log 2>&1; ec=$? | |
| cat aid_interrupt.log; exit $ec | |
| # Drives aid through a real pty (the editor only exists on a terminal), and | |
| # one of its cases is the interrupt suite's blocking `up` interrupted at the | |
| # editor — so it gets the same log-pipe fence, for the same reason. | |
| - name: Test aid interactive | |
| working-directory: rust | |
| run: | | |
| timeout -k 10 300 cargo test --locked -p aid --test interactive -- --test-threads=1 --nocapture > aid_interactive.log 2>&1; ec=$? | |
| cat aid_interactive.log; exit $ec | |
| - name: Test dl (lib + unit) | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --lib --bins -- --test-threads=1 --nocapture | |
| - name: Test dl read_side | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --test read_side -- --test-threads=1 --nocapture | |
| - name: Test dl grammar | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --test grammar -- --test-threads=1 --nocapture | |
| # Named here because the steps above name one binary each: a test binary | |
| # left out of this list is built by the `Build` step and run by nothing, so | |
| # it reads exactly like a check that passed. This one is the completion | |
| # script's tables diffed against the grammar that defines them, which is a | |
| # guard that would be worth nothing unrun. | |
| - name: Test dl completion tables | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --test completion_tables -- --test-threads=1 --nocapture | |
| - name: Test dl lifecycle | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --test lifecycle -- --test-threads=1 --nocapture | |
| - name: Test dl launch | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --test launch -- --test-threads=1 --nocapture | |
| - name: Test dl migration | |
| working-directory: rust | |
| run: timeout 300 cargo test --locked -p dl --test migration -- --test-threads=1 --nocapture | |
| # These two suites spawn and kill real process trees. Their cargo output | |
| # goes to a file and is cat'ed after, so a test child that outlives the | |
| # timeout can only hold the file open — never this step's log pipe, which | |
| # is what the runner waits on before it will end the step. A held pipe | |
| # here is what killed whole runners at exactly 46m ("lost communication | |
| # with the server") with the step still showing in_progress. | |
| - name: Test dl lock_wait | |
| working-directory: rust | |
| run: | | |
| timeout -k 10 300 cargo test --locked -p dl --test lock_wait -- --test-threads=1 --nocapture > lock_wait.log 2>&1; ec=$? | |
| cat lock_wait.log; exit $ec | |
| - name: Test dl interrupt | |
| working-directory: rust | |
| run: | | |
| timeout -k 10 300 cargo test --locked -p dl --test interrupt -- --test-threads=1 --nocapture > interrupt.log 2>&1; ec=$? | |
| cat interrupt.log; exit $ec | |
| # And then the whole thing, because everything above is a list somebody | |
| # maintains by hand and a list is a thing a suite falls off. | |
| # | |
| # The steps above are worth their keep -- a per-suite `timeout` so a wedged | |
| # binary fails its own short step, and a step title that names the culprit | |
| # even when the runner dies before uploading logs -- but they make the list, | |
| # rather than the workspace, the definition of what runs. A test binary | |
| # nobody added a step for is built by `Build` above and run by nothing, and | |
| # nothing anywhere goes red to say so: a check nobody ran reads exactly like | |
| # a check that passed, which is `gate`'s own argument applied one level down. | |
| # | |
| # It had already happened twice when this step was added. | |
| # `devlaunch-test-support` came off the list once and went back with a | |
| # comment about it; `dl`'s `picker` and `terminal` suites, 8 tests, were | |
| # still off it -- `terminal` being the guard on the terminal-restore fix from | |
| # the week before, so the one test that proved that repair had never run in | |
| # CI. Neither was a mistake anyone could have seen by reading a green tick. | |
| # | |
| # So the list stays for triage and the workspace decides what runs. It costs | |
| # about what the named steps cost, since it is the same tests over again: | |
| # measured at 72s, taking the job from 2:13 to 3:19 against a 30-minute | |
| # bound, which is why nothing else here needed resizing. Same | |
| # `--test-threads=1` (the concurrency tests must not interleave) and the same | |
| # log-pipe fence as the two steps above, for the same reason -- this run | |
| # includes them. | |
| # | |
| # `|| ec=$?` rather than `; ec=$?`, which is the difference between a | |
| # failure you can read and a red tick with an empty log. GitHub runs a | |
| # `run:` block under `bash -e`, so on a non-zero `timeout` the shell exits | |
| # at that line: `ec=$?` and the `cat` never happen, and the only copy of | |
| # cargo's output is a file in a workspace that is about to be thrown away. | |
| # `||` is a tested condition, which errexit does not fire on, so the log | |
| # always prints and `$ec` still carries the code (124 when the timeout is | |
| # what fired). That matters most here of anywhere: this step is the only | |
| # place `picker` and `terminal` run at all. | |
| - name: Test the whole workspace (the backstop under the list above) | |
| working-directory: rust | |
| run: | | |
| ec=0 | |
| timeout -k 10 900 cargo test --workspace --locked -- --test-threads=1 > workspace.log 2>&1 || ec=$? | |
| cat workspace.log; exit $ec | |
| - name: Clippy | |
| working-directory: rust | |
| run: cargo clippy --locked --all-targets -- -D warnings | |
| - name: Format | |
| working-directory: rust | |
| run: cargo fmt --check | |
| # What the crates that ship actually cover, measured (#294). | |
| # | |
| # Until this job existed, nothing measured them. `codecov` has been fed since | |
| # long before the port, but what it read was `coverage run -m pytest` over the | |
| # Python tree — so when that tree retired (#267) the number it reports became a | |
| # number about `scripts/`, and the shipped code's coverage was nobody's. The | |
| # `ci` job still uploads that one, under the `python` flag; this one uploads | |
| # `rust` beside it, and `codecov.yml` keeps the two from being averaged into a | |
| # figure that is about neither. | |
| # | |
| # A job of its own rather than instrumentation added to `rust` above, for two | |
| # reasons. `-C instrument-coverage` changes codegen and roughly triples these | |
| # suites' wall time, and `rust` is the job whose green tick means the tests | |
| # pass — a measurement should not be able to redden it, or to push it into the | |
| # per-binary timeouts that are there to name a wedged suite. And cargo-llvm-cov | |
| # builds into a target directory of its own, so there is nothing to share by | |
| # merging them anyway: it would be two builds either way, and this way they are | |
| # two builds in parallel. | |
| # | |
| # It is in `gate`'s `needs` all the same. A job outside the gate is a job whose | |
| # failure nobody sees, and a coverage run that silently stopped running is | |
| # exactly the hole this closes — see the `gate` comment on why "a check nobody | |
| # ran reads exactly like a check that passed". | |
| rust-coverage: | |
| runs-on: ubuntu-latest | |
| # The `rust` job's 30 with the instrumented rebuild and the slower suites on | |
| # top. Bounded for the same reason everything here is: unbounded, a stalled | |
| # download is a job that hangs for six hours and reports a timeout nobody | |
| # reads. | |
| timeout-minutes: 45 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| # Kept in lockstep with rust/rust-toolchain.toml (the pin of record). | |
| toolchain: 1.97.1 | |
| # `llvm-profdata` and `llvm-cov`, which cargo-llvm-cov drives. The pixi | |
| # environment gets the same two from conda's `llvm-tools` instead, since | |
| # it has no rustup to take a component from; `pyproject.toml` carries | |
| # the note on keeping their LLVM major and rustc's together. | |
| components: llvm-tools-preview | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: rust | |
| # A cache of its own: the instrumented artifacts share no fingerprint | |
| # with the `rust` job's, so one key for both would be two jobs evicting | |
| # each other's build every run. | |
| key: coverage | |
| - uses: taiki-e/install-action@cargo-llvm-cov | |
| # One reduced report over every suite, which is what makes the number mean | |
| # anything: run per-suite and each one's report is about the lines that suite | |
| # reached, and the last one written wins. | |
| # | |
| # `--no-report` collects; `report` reduces. The suites are named rather than | |
| # `--workspace`ed for the same reason the `rust` job names them — but here it | |
| # buys something more: the two that spawn and kill real process trees | |
| # (`interrupt`, `lock_wait`, one test each) are deliberately **left out**. A | |
| # SIGKILLed process writes no counters, so they would contribute nothing to | |
| # the report while costing it the most flake risk in the suite. That is a cap | |
| # on coverage and it is said out loud here rather than being visible only as | |
| # a number: 2 tests of ~1470. | |
| - name: Collect | |
| working-directory: rust | |
| run: | | |
| set -eu | |
| # The one thing that makes this report about *this* run. `rust-cache` | |
| # restores `target/`, `--no-report` runs accumulate on purpose, and a | |
| # `.profraw` that survived from a previous run is indistinguishable from | |
| # one this run wrote -- it would raise the number using counters for code | |
| # as it was on another commit. | |
| cargo llvm-cov clean --workspace | |
| for suite in \ | |
| "-p devlaunch-runner" \ | |
| "-p devlaunch-core" \ | |
| "-p devlaunch-test-support" \ | |
| "-p aid --bins --test rewrite" \ | |
| "-p dl --lib --bins" \ | |
| "-p dl --test read_side" \ | |
| "-p dl --test grammar" \ | |
| "-p dl --test completion_tables" \ | |
| "-p dl --test lifecycle" \ | |
| "-p dl --test launch" \ | |
| "-p dl --test migration" \ | |
| ; do | |
| echo "::group::cargo llvm-cov $suite" | |
| timeout 900 cargo llvm-cov --no-report --locked $suite -- --test-threads=1 | |
| echo "::endgroup::" | |
| done | |
| # `SF:` paths come out absolute (`/home/runner/work/devlaunch/devlaunch/rust/ | |
| # devlaunch-core/src/...`), and repo-relative is what everything downstream | |
| # matches on: `codecov.yml`'s flag and component `paths:` are relative, so an | |
| # absolute path is a file in no flag and no component -- an upload that | |
| # succeeds and reports on nothing. Relativised here rather than trusting the | |
| # uploader's own path fixing, because the failure is silent either way and | |
| # this is one `sed`. | |
| - name: Report | |
| working-directory: rust | |
| run: | | |
| set -eu | |
| cargo llvm-cov report --lcov --output-path lcov.info | |
| sed -i "s|^SF:${GITHUB_WORKSPACE}/|SF:|" lcov.info | |
| cargo llvm-cov report --summary-only | |
| # Two things an upload cannot tell you on its own: whether what it carries is | |
| # about the code that ships, and whether the paths in it are the ones the | |
| # flag filters will match. A report that measures the harness only, or one | |
| # whose every path is absolute, uploads cleanly and reads as a coverage drop | |
| # caused by nothing. | |
| - name: The report is about the crates that ship | |
| working-directory: rust | |
| run: | | |
| set -eu | |
| for crate in devlaunch-core dl aid; do | |
| grep -q "^SF:rust/${crate}/src/" lcov.info || { | |
| echo "::error::lcov.info records no repo-relative file under rust/${crate}/src/" | |
| exit 1 | |
| } | |
| done | |
| ! grep -q "^SF:/" lcov.info || { | |
| echo "::error::lcov.info still carries absolute paths; codecov.yml's flags match none of them" | |
| grep -m3 "^SF:/" lcov.info | |
| exit 1 | |
| } | |
| echo "lcov.info covers $(grep -c '^SF:' lcov.info) files" | |
| - name: Upload coverage reports to Codecov | |
| uses: codecov/codecov-action@v7 | |
| with: | |
| files: rust/lcov.info | |
| flags: rust | |
| env: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| # The API-freeze tripwire (#251 §7, #262, re-scoped by #312). devlaunch-core's | |
| # crate docs promise two tiers of `pub` — the frozen wf API behind `api`, and | |
| # binary surface that is reachable but not promised — and this job is what | |
| # makes that promise checkable rather than prose: any change to a checked-in | |
| # `cargo public-api` snapshot fails here until the new one is committed. That | |
| # turns every surface change into a reviewed diff: an addition to `api` is a | |
| # deliberate PR, a removal a visible breaking change, and an accidental `pub` | |
| # a red tick instead of a silent promise. | |
| # | |
| # Three snapshots, because one file could not tell those tiers apart. It held | |
| # both, so a breaking `api` change arrived as one row inside two thousand of | |
| # internal churn and read as routine; and `devlaunch-runner` — the trait an | |
| # external implementer writes against — entered it as a single unexpanded glob | |
| # row, so removing a `Runner` method moved nothing at all. Now: | |
| # devlaunch-core/public-api.api.txt is the promise as a path match can see it, | |
| # devlaunch-core/public-api.rest.txt the tripwire over the binary surface, and | |
| # devlaunch-runner/public-api.txt the process seam. | |
| # | |
| # "As a path match can see it" was the honest scope until #352 and is no | |
| # longer the scope at all. cargo public-api renders methods and impls only at | |
| # a type's canonical path, so matching the `api` path alone kept the promised | |
| # types' names and left every one of their constructors, methods and derived | |
| # impls in the rest file — renaming `api::Launch::run` diffed neither file | |
| # where a reader would look. The classifier now resolves each `api` re-export | |
| # back to the path it names and claims that item's rows too, which is why most | |
| # of the promise file is written at `flows::` and `domain::` paths. | |
| # | |
| # What is left over is still real and is not one type: 39 types that a | |
| # promised signature hands back but `api` never re-exports own over six | |
| # hundred rows in the rest file, `domain::spec::DevcontainerRefError` — the | |
| # error type of the promised `api::resolve_devcontainer_ref` — among them. | |
| # So a rest-file diff is routine for a row whose subject nothing promised | |
| # names and a contract change for a row whose subject is one of those; | |
| # `--print-residual` tells them apart. | |
| # | |
| # Nightly because cargo-public-api's rustdoc-JSON backend needs it; the crates | |
| # themselves still build on the stable pin everywhere else. The pinned | |
| # version, the `-ss` rationale, the split filter and the list of files to | |
| # check all live in the regeneration script, which is also what this job runs | |
| # — so what CI checks cannot drift from what a developer regenerates, and a | |
| # fourth snapshot needs no workflow edit to be covered. | |
| public-api: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@nightly | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: rust | |
| - name: Install cargo-public-api (the pin the script names) | |
| run: | | |
| set -euo pipefail | |
| cargo install cargo-public-api --locked \ | |
| --version "$(scripts/public-api-snapshots.sh --print-pin)" | |
| - name: The public surface is the snapshots the repo carries | |
| run: | | |
| set -euo pipefail | |
| scripts/public-api-snapshots.sh "$RUNNER_TEMP/public-api" | |
| changed=0 | |
| checked=0 | |
| while read -r snapshot; do | |
| checked=$((checked + 1)) | |
| diff -u "rust/$snapshot" "$RUNNER_TEMP/public-api/$snapshot" || changed=1 | |
| done < <(scripts/public-api-snapshots.sh --print-files) | |
| # Count what was compared, in the same spirit as the `gate` job's | |
| # "this gate covers no jobs" check. `set -e` does not see a process | |
| # substitution fail, and a loop that runs zero times leaves changed=0 | |
| # — so without this, a --print-files that printed nothing (or exited | |
| # non-zero) would report success having diffed not one file, which is | |
| # the exact shape of tripwire this job exists to stop being. | |
| if [ "$checked" -eq 0 ]; then | |
| echo "::error::--print-files listed no snapshots, so this job compared" \ | |
| "nothing. The list lives in scripts/public-api-snapshots.sh; a green" \ | |
| "tick here would mean the public surface is unchecked, not unchanged." | |
| exit 1 | |
| fi | |
| if [ "$changed" -ne 0 ]; then | |
| echo "::error::A public surface changed. If the change is deliberate," \ | |
| "regenerate the snapshots and commit them:" \ | |
| "scripts/public-api-snapshots.sh (needs a nightly toolchain and the" \ | |
| "cargo-public-api the script pins; see 'The public-API snapshots' in" \ | |
| "docs/development.md). A diff in devlaunch-core/public-api.api.txt is a change" \ | |
| "to the promised API — say which. A promised type's methods and impls are" \ | |
| "in there too, under the canonical path they are rendered at, so a diff" \ | |
| "that names a flows:: or domain:: path in that file is still a promise." | |
| exit 1 | |
| fi | |
| # The release mechanics, exercised on every pull request instead of on release | |
| # day. Both published artifacts are compiled now (docs/rust-rewrite-plan.md M10): | |
| # a maturin bin-wheel for PyPI and a `cargo install` conda package for | |
| # prefix.dev, both taking their version from `rust/Cargo.toml`. Nothing else in | |
| # this workflow builds either, so without this job a broken recipe or a lost | |
| # `wheel` feature flag would first be discovered by `Auto-publish` on `main`, | |
| # after the merge that caused it -- and a release that fails halfway leaves a | |
| # version published to one channel and not the other. | |
| # | |
| # It is not the release: the wheel here is built on the runner rather than in the | |
| # manylinux container `publish.yml` uses (so its platform tag is the runner's, | |
| # which this job deliberately does not assert), and the conda side is rendered | |
| # rather than built, because building it means a second full cargo build in a | |
| # conda environment for no answer this job does not already get. What it proves | |
| # is the wiring: both bin targets reach the wheel, the version is the | |
| # single-sourced one everywhere it is read, and the recipe parses. | |
| packaging: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| # Kept in lockstep with rust/rust-toolchain.toml (the pin of record). | |
| toolchain: 1.97.1 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: rust | |
| - name: Build the bin-wheel | |
| uses: PyO3/maturin-action@v1 | |
| with: | |
| working-directory: packaging/wheel | |
| # Pinned, and kept in lockstep with `publish.yml`'s build step. The | |
| # action's default is `latest`, which is how maturin 1.14 arrived | |
| # unbidden and broke the build (1.14 rejects a `readme` path resolving | |
| # outside the metadata root; see the symlink note in | |
| # `packaging/wheel/pyproject.toml`). A pin means the maturin CI builds | |
| # with only ever moves when someone edits these two files. | |
| maturin-version: v1.14.1 | |
| # The one difference from `publish.yml`: no manylinux container, so the | |
| # build reuses this job's toolchain and cargo cache and takes a couple of | |
| # minutes instead of a cold container build. The wheel it produces is | |
| # tagged for the runner's own glibc, which is exactly why the check below | |
| # asserts contents and version and says nothing about the tag. | |
| container: off | |
| args: --release --locked --out dist | |
| - name: The wheel is the two binaries, at the version rust/Cargo.toml names | |
| run: | | |
| set -euo pipefail | |
| version=$(sed -n 's/^version = "\(.*\)"/\1/p' rust/Cargo.toml | head -1) | |
| wheel=$(ls -1 packaging/wheel/dist/*.whl) | |
| python3 scripts/check_wheel.py "$wheel" --version "$version" | |
| python3 -m venv /tmp/wheel-check | |
| /tmp/wheel-check/bin/pip install --quiet "$wheel" | |
| test "$(/tmp/wheel-check/bin/dl --version)" = "dl $version" | |
| test "$(/tmp/wheel-check/bin/aid --version)" = "aid $version" | |
| - uses: prefix-dev/setup-pixi@v0.10.2 | |
| with: | |
| run-install: false | |
| - name: The conda recipe parses and names the same version | |
| run: | | |
| set -euo pipefail | |
| version=$(sed -n 's/^version = "\(.*\)"/\1/p' rust/Cargo.toml | head -1) | |
| # `--experimental` for the recipe's `load_from_file`; see the comment in | |
| # conda-publish.yml's build step. `--render-only` prints the rendered | |
| # recipe as JSON on *stdout*; rattler-build's `devlaunch-<ver>-<hash>` | |
| # build-string line is fancy-formatted logging on stderr, so a | |
| # `grep devlaunch-$version-` over stdout matches nothing -- assert against | |
| # the JSON instead, which is stable across rattler-build versions. This | |
| # also confirms the `conda.recipe/variants.yaml` rust floor rendered into | |
| # the build requirements rather than silently dropping out. | |
| pixi exec rattler-build build --experimental --render-only \ | |
| --recipe conda.recipe/recipe.yaml > /tmp/rendered.txt | |
| python3 -c ' | |
| import json, sys | |
| expected = sys.argv[1] | |
| rendered = json.load(open("/tmp/rendered.txt")) | |
| if not rendered: | |
| sys.exit("render produced no recipe variants") | |
| for variant in rendered: | |
| pkg = variant["recipe"]["package"] | |
| if pkg["name"] != "devlaunch": | |
| sys.exit("recipe names package " + repr(pkg["name"]) + ", expected devlaunch") | |
| if pkg["version"] != expected: | |
| sys.exit("recipe version " + repr(pkg["version"]) + " != rust/Cargo.toml " + repr(expected)) | |
| build = variant["recipe"]["requirements"]["build"] | |
| if not any(dep.startswith("rust_") and ">=1.85" in dep for dep in build): | |
| sys.exit("rust compiler floor >=1.85 missing from build deps: " + repr(build)) | |
| print("conda recipe renders devlaunch " + expected + " with the rust >=1.85 floor") | |
| ' "$version" | |
| # One name for a branch ruleset to require, standing for every job above it. | |
| # | |
| # A ruleset names required checks as literal strings, so requiring the jobs | |
| # individually means `ci (py310)`, `ci (py311)`, `ci (py312)`, `ci (py313)`, | |
| # `ci (default)` and `e2e` written out in a repository setting -- a list that | |
| # says nothing about why, that nobody reviews, and that goes stale the moment | |
| # a matrix leg or a job is added or renamed here. The stale case is the | |
| # dangerous one, because a required check that no longer exists is not a red | |
| # tick: it is a merge that sails through ungated. Requiring `gate` instead | |
| # puts the decision in this file, next to the jobs it is about, where a pull | |
| # request that adds a job also adds it to `needs` under review. | |
| # | |
| # It covers this workflow and only this workflow: `needs` cannot reach across | |
| # files, so the lint workflow's `prek` job is not in here and has to be | |
| # required alongside `gate` rather than through it. | |
| # | |
| # `always()` on both the job and the step is load-bearing, and it is `always()` | |
| # rather than `!cancelled()` on purpose. Without it on the job, the gate is | |
| # skipped whenever anything it needs fails -- and a skipped job reports neither | |
| # success nor failure. Without it on the step, a cancelled run skips the step | |
| # while still running the job. And `!cancelled()` would leave the gate *skipped* | |
| # when a run is cancelled, which a ruleset reads as satisfied: the unsafe | |
| # direction of the two. | |
| # | |
| # The check insists on `success` rather than merely on the absence of | |
| # `failure`, so `cancelled` and `skipped` fail it too, and it refuses to pass | |
| # on an empty list. Both of those are the same point, which is the point of the | |
| # whole workflow: a check nobody ran reads exactly like a check that passed, | |
| # and a gate that covers nothing is the purest form of that. | |
| # A check nobody ran reads exactly like a check that passed -- `gate`'s own | |
| # comment says so, and the external reviewer is where it happened. Sourcery | |
| # answers a quota refusal *as a review*, so | |
| # | |
| # Sorry @blooop, you have reached your weekly rate limit of 500000 diff | |
| # characters. | |
| # | |
| # arrives in the same shape as a review that found nothing. Twenty-six | |
| # consecutive pull requests merged behind that sentence, among them the largest | |
| # changes in the repo, and nothing said so. | |
| # | |
| # What this asks is "was this reviewed", not "did Sourcery answer". A quota | |
| # outage lasts a week, and a guard that stops all merges for a week is a guard | |
| # that gets deleted -- so a `wf-review` report by the author satisfies it, which | |
| # is the review this repo actually runs when the bot is out. The classification | |
| # is in `scripts/review_verdict.sh` rather than in this block, for the reason | |
| # the public-api script is a script: a `case` that exists only inside a `run:` | |
| # is testable only by copying it, and the copy goes stale. | |
| review: | |
| runs-on: ubuntu-latest | |
| # The one job here that sleeps on a remote answer: up to two minutes of | |
| # polling for a review that has not been posted yet. A poll loop against an | |
| # API is the shape that hangs, so this is the job that least deserved to be | |
| # the one running unbounded. | |
| timeout-minutes: 10 | |
| permissions: | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: A pull request nothing reviewed is a pull request nobody read | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| EVENT: ${{ github.event_name }} | |
| PR: ${{ github.event.pull_request.number }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| REPO: ${{ github.repository }} | |
| OVERRIDE_LABEL: no-external-review | |
| run: | | |
| set -euo pipefail | |
| if [ "$EVENT" != pull_request ]; then | |
| echo "not a pull request, so there is nothing to have been reviewed" | |
| exit 0 | |
| fi | |
| if gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' \ | |
| | grep -qxF "$OVERRIDE_LABEL"; then | |
| echo "::notice::'$OVERRIDE_LABEL' is set, so this pull request merges"\ | |
| "unreviewed. That is the label's whole purpose; it is recorded"\ | |
| "here so the choice is visible afterwards." | |
| exit 0 | |
| fi | |
| # Sourcery answers within a minute of the pull request opening, well | |
| # inside the shortest job here -- but "well inside" is not "before", and | |
| # a race lost would fail a pull request for the reviewer's latency | |
| # rather than for anything about the change. Poll, and let the remaining | |
| # ~2 minutes of CI absorb the wait. A self-review that is already posted | |
| # short-circuits the wait on the first pass. | |
| reviews='[]' | |
| for _ in $(seq 1 12); do | |
| reviews=$(gh api "repos/$REPO/pulls/$PR/reviews" \ | |
| --jq '[.[] | {author: .user.login, body: .body, state: .state, commit: .commit_id}]' || echo '[]') | |
| [ "$(jq 'length' <<<"$reviews")" -gt 0 ] && break | |
| sleep 10 | |
| done | |
| printf '%s' "$reviews" | ./scripts/review_verdict.sh | |
| gate: | |
| needs: [ci, e2e, rust, rust-coverage, public-api, packaging, review] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| # Seconds of shell over a list `needs` already resolved. Bounded anyway, | |
| # because this is the check a ruleset requires: a `gate` still spinning six | |
| # hours after the jobs it covers finished is a merge nobody can complete and | |
| # nobody can diagnose. | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Every job this gate covers must have succeeded | |
| if: always() | |
| env: | |
| RESULTS: ${{ join(needs.*.result, ' ') }} | |
| run: | | |
| set -eu | |
| echo "results: $RESULTS" | |
| if [ -z "$RESULTS" ]; then | |
| echo "::error::this gate covers no jobs, so it is not gating anything" | |
| exit 1 | |
| fi | |
| for result in $RESULTS; do | |
| if [ "$result" != success ]; then | |
| echo "::error::a job this gate covers reported '$result'" | |
| exit 1 | |
| fi | |
| done |