Skip to content

ci: the publish channel comes from a variable, and a fork can cut its… #1

ci: the publish channel comes from a variable, and a fork can cut its…

ci: the publish channel comes from a variable, and a fork can cut its… #1

Workflow file for this run

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