forked from blooop/devlaunch
-
Notifications
You must be signed in to change notification settings - Fork 0
806 lines (791 loc) · 43.1 KB
/
Copy pathci.yml
File metadata and controls
806 lines (791 loc) · 43.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
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
# Hoisted from the upload step so a step-level `if` can read it: the
# `secrets` context is not available in `if`, and `env` is.
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
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"
# The tags are the guard's oracle for the one edit it permits: a
# released section moving back to what that version actually shipped.
# Shallow, because only the CHANGELOG.md at each tag is ever read.
# Missing tags are not an error here -- the guard refuses on a tag it
# cannot reach, so a fetch that comes up short fails closed.
git fetch --depth=1 --tags origin || true
# `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
# blooop/devlaunch#591. Two branches bumped to 0.36.0 independently and git
# merged it silently, because the same number on both sides is the identical
# edit and there is nothing to conflict on. The second merge then published
# nothing. This is the last point where the two versions still differ: by the
# time the merge is on `main` its version equals its first parent's, so
# `publish.yml` has nothing to notice.
#
# Same `BASE_SHA` the step above fetched, which is the base as of the pull
# request rather than the moving tip of `main` -- for #591 that was the commit
# before the rival release landed, where the version was still 0.35.0.
- name: A version that is already published is not one to bump to
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
git show "$BASE_SHA:rust/Cargo.toml" > "$RUNNER_TEMP/base-Cargo.toml"
python3 scripts/version_untaken.py \
"$RUNNER_TEMP/base-Cargo.toml" rust/Cargo.toml
# 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
# Skipped wherever no token is configured, which is every fork: secrets
# are not inherited, so this was a red job about somebody else's
# coverage project. Upstream sets the secret and this runs as before.
if: env.CODECOV_TOKEN != ''
uses: codecov/codecov-action@v7
with:
flags: python
# 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
# Hoisted from the upload step so a step-level `if` can read it: the
# `secrets` context is not available in `if`, and `env` is.
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
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
# Skipped wherever no token is configured, which is every fork: secrets
# are not inherited, so this was a red job about somebody else's
# coverage project. Upstream sets the secret and this runs as before.
if: env.CODECOV_TOKEN != ''
uses: codecov/codecov-action@v7
with:
files: rust/lcov.info
flags: rust
# 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: the 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.
#
# Rendered by the pinned *stable* toolchain under RUSTC_BOOTSTRAP=1, not by a
# nightly -- deliberately, and not merely because this environment lacks one.
# `cargo public-api`'s rustdoc-JSON backend wants a nightly toolchain, and
# nightly's rustdoc rendering moves on its own: switching toolchains changed
# a handful of rows here once with no source change behind it at all (a
# `core::io::error::Error` path rendering as `std::io::error::Error`, an
# auto-derived impl's bound changing shape), which is exactly the class of
# failure -- a red tick nobody's commit caused -- this job exists to not be.
# `pixi run public-api` is the one task that installs the pin (skipping the
# install once it is already there), assembles that environment, and runs
# scripts/public-api-snapshots.sh; this job runs the identical task, so what
# CI checks cannot drift from what a developer regenerates with, and a
# fourth snapshot needs no workflow edit to be covered. See "The public-API
# snapshots" in docs/development.md and scripts/run-public-api.sh's header
# for the rest of it.
public-api:
runs-on: ubuntu-latest
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
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: The public surface is the snapshots the repo carries
run: |
set -euo pipefail
pixi run public-api "$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: pixi run public-api" \
"(installs the pinned cargo-public-api itself; 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