anvilsign in

collin/anvil · b26efa9c

Add CI foundation (pipeline + run model + push trigger); switch SSH crypto to ring

Collin Richards · 2026-06-09 20:36 UTC · b26efa9c0a4475d903149a1c8169e36b4dd081ec · parent 67ea942a · browse files

modifiedCargo.lock+124 −84
⋯ 104 unchanged lines
105105 source = "registry+https://github.com/rust-lang/crates.io-index"
106106 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
107107 dependencies = [
108- "windows-sys",
108+ "windows-sys 0.61.2",
109109 ]
110110
111111 [[package]]
⋯ 4 unchanged lines
116116 dependencies = [
117117 "anstyle",
118118 "once_cell_polyfill",
119- "windows-sys",
119+ "windows-sys 0.61.2",
120120 ]
121121
122122 [[package]]
⋯ 17 unchanged lines
140140 "argon2 0.5.3",
141141 "gix",
142142 "serde",
143+ "serde_yaml",
143144 "ssh-key",
144145 "tempfile",
145146 "thiserror",
⋯ 11 unchanged lines
157158 "gitserver-core",
158159 "gix",
159160 "thiserror",
161+ "toasty",
160162 "tokio",
161163 "tracing",
162164 ]
⋯ 99 unchanged lines
262264 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
263265
264266 [[package]]
265-name = "aws-lc-rs"
266-version = "1.17.0"
267-source = "registry+https://github.com/rust-lang/crates.io-index"
268-checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
269-dependencies = [
270- "aws-lc-sys",
271- "untrusted",
272- "zeroize",
273-]
274-
275-[[package]]
276-name = "aws-lc-sys"
277-version = "0.41.0"
278-source = "registry+https://github.com/rust-lang/crates.io-index"
279-checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
280-dependencies = [
281- "cc",
282- "cmake",
283- "dunce",
284- "fs_extra",
285-]
286-
287-[[package]]
288267 name = "axum"
289268 version = "0.8.9"
290269 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 252 unchanged lines
543522 checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
544523 dependencies = [
545524 "find-msvc-tools",
546- "jobserver",
547- "libc",
548525 "shlex",
549526 ]
550527
⋯ 97 unchanged lines
648625 ]
649626
650627 [[package]]
651-name = "cmake"
652-version = "0.1.58"
653-source = "registry+https://github.com/rust-lang/crates.io-index"
654-checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
655-dependencies = [
656- "cc",
657-]
658-
659-[[package]]
660628 name = "cmov"
661629 version = "0.5.4"
662630 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 390 unchanged lines
10531021 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
10541022 dependencies = [
10551023 "libc",
1056- "windows-sys",
1024+ "windows-sys 0.61.2",
10571025 ]
10581026
10591027 [[package]]
⋯ 105 unchanged lines
11651133 ]
11661134
11671135 [[package]]
1168-name = "fs_extra"
1169-version = "1.3.0"
1170-source = "registry+https://github.com/rust-lang/crates.io-index"
1171-checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1172-
1173-[[package]]
11741136 name = "futures"
11751137 version = "0.3.32"
11761138 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 115 unchanged lines
12921254
12931255 [[package]]
12941256 name = "getrandom"
1295-version = "0.3.4"
1296-source = "registry+https://github.com/rust-lang/crates.io-index"
1297-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1298-dependencies = [
1299- "cfg-if",
1300- "libc",
1301- "r-efi 5.3.0",
1302- "wasip2",
1303-]
1304-
1305-[[package]]
1306-name = "getrandom"
13071257 version = "0.4.2"
13081258 source = "registry+https://github.com/rust-lang/crates.io-index"
13091259 checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
⋯ 1 unchanged line
13111261 "cfg-if",
13121262 "js-sys",
13131263 "libc",
1314- "r-efi 6.0.0",
1264+ "r-efi",
13151265 "rand_core 0.10.1",
13161266 "wasip2",
13171267 "wasip3",
⋯ 604 unchanged lines
19221872 "bitflags",
19231873 "gix-path",
19241874 "libc",
1925- "windows-sys",
1875+ "windows-sys 0.61.2",
19261876 ]
19271877
19281878 [[package]]
⋯ 592 unchanged lines
25212471 checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
25222472 dependencies = [
25232473 "jiff-tzdb",
2524-]
2525-
2526-[[package]]
2527-name = "jobserver"
2528-version = "0.1.34"
2529-source = "registry+https://github.com/rust-lang/crates.io-index"
2530-checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
2531-dependencies = [
2532- "getrandom 0.3.4",
2533- "libc",
25342474 ]
25352475
25362476 [[package]]
⋯ 209 unchanged lines
27462686 dependencies = [
27472687 "libc",
27482688 "wasi",
2749- "windows-sys",
2689+ "windows-sys 0.61.2",
27502690 ]
27512691
27522692 [[package]]
⋯ 45 unchanged lines
27982738 source = "registry+https://github.com/rust-lang/crates.io-index"
27992739 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
28002740 dependencies = [
2801- "windows-sys",
2741+ "windows-sys 0.61.2",
28022742 ]
28032743
28042744 [[package]]
⋯ 396 unchanged lines
32013141 dependencies = [
32023142 "proc-macro2",
32033143 ]
3204-
3205-[[package]]
3206-name = "r-efi"
3207-version = "5.3.0"
3208-source = "registry+https://github.com/rust-lang/crates.io-index"
3209-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
32103144
32113145 [[package]]
32123146 name = "r-efi"
⋯ 76 unchanged lines
32893223 ]
32903224
32913225 [[package]]
3226+name = "ring"
3227+version = "0.17.14"
3228+source = "registry+https://github.com/rust-lang/crates.io-index"
3229+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
3230+dependencies = [
3231+ "cc",
3232+ "cfg-if",
3233+ "getrandom 0.2.17",
3234+ "libc",
3235+ "untrusted",
3236+ "windows-sys 0.52.0",
3237+]
3238+
3239+[[package]]
32923240 name = "rsa"
32933241 version = "0.10.0-rc.18"
32943242 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 44 unchanged lines
33393287 checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
33403288 dependencies = [
33413289 "aes",
3342- "aws-lc-rs",
33433290 "bitflags",
33443291 "block-padding",
33453292 "byteorder",
⋯ 37 unchanged lines
33833330 "polyval",
33843331 "rand",
33853332 "rand_core 0.10.1",
3333+ "ring",
33863334 "rsa",
33873335 "russh-cryptovec",
33883336 "russh-util",
⋯ 24 unchanged lines
34133361 "log",
34143362 "nix",
34153363 "ssh-encoding",
3416- "windows-sys",
3364+ "windows-sys 0.61.2",
34173365 ]
34183366
34193367 [[package]]
⋯ 27 unchanged lines
34473395 "errno",
34483396 "libc",
34493397 "linux-raw-sys",
3450- "windows-sys",
3398+ "windows-sys 0.61.2",
34513399 ]
34523400
34533401 [[package]]
⋯ 141 unchanged lines
35953543 ]
35963544
35973545 [[package]]
3546+name = "serde_yaml"
3547+version = "0.9.34+deprecated"
3548+source = "registry+https://github.com/rust-lang/crates.io-index"
3549+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
3550+dependencies = [
3551+ "indexmap",
3552+ "itoa",
3553+ "ryu",
3554+ "serde",
3555+ "unsafe-libyaml",
3556+]
3557+
3558+[[package]]
35983559 name = "serdect"
35993560 version = "0.4.3"
36003561 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 128 unchanged lines
37293690 checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
37303691 dependencies = [
37313692 "libc",
3732- "windows-sys",
3693+ "windows-sys 0.61.2",
37333694 ]
37343695
37353696 [[package]]
⋯ 162 unchanged lines
38983859 "getrandom 0.4.2",
38993860 "once_cell",
39003861 "rustix",
3901- "windows-sys",
3862+ "windows-sys 0.61.2",
39023863 ]
39033864
39043865 [[package]]
⋯ 177 unchanged lines
40824043 "signal-hook-registry",
40834044 "socket2",
40844045 "tokio-macros",
4085- "windows-sys",
4046+ "windows-sys 0.61.2",
40864047 ]
40874048
40884049 [[package]]
⋯ 247 unchanged lines
43364297 ]
43374298
43384299 [[package]]
4300+name = "unsafe-libyaml"
4301+version = "0.2.11"
4302+source = "registry+https://github.com/rust-lang/crates.io-index"
4303+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
4304+
4305+[[package]]
43394306 name = "untrusted"
4340-version = "0.7.1"
4307+version = "0.9.0"
43414308 source = "registry+https://github.com/rust-lang/crates.io-index"
4342-checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
4309+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
43434310
43444311 [[package]]
43454312 name = "url"
⋯ 178 unchanged lines
45244491 source = "registry+https://github.com/rust-lang/crates.io-index"
45254492 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
45264493 dependencies = [
4527- "windows-sys",
4494+ "windows-sys 0.61.2",
45284495 ]
45294496
45304497 [[package]]
⋯ 99 unchanged lines
46304597
46314598 [[package]]
46324599 name = "windows-sys"
4600+version = "0.52.0"
4601+source = "registry+https://github.com/rust-lang/crates.io-index"
4602+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
4603+dependencies = [
4604+ "windows-targets",
4605+]
4606+
4607+[[package]]
4608+name = "windows-sys"
46334609 version = "0.61.2"
46344610 source = "registry+https://github.com/rust-lang/crates.io-index"
46354611 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
⋯ 2 unchanged lines
46384614 ]
46394615
46404616 [[package]]
4617+name = "windows-targets"
4618+version = "0.52.6"
4619+source = "registry+https://github.com/rust-lang/crates.io-index"
4620+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
4621+dependencies = [
4622+ "windows_aarch64_gnullvm",
4623+ "windows_aarch64_msvc",
4624+ "windows_i686_gnu",
4625+ "windows_i686_gnullvm",
4626+ "windows_i686_msvc",
4627+ "windows_x86_64_gnu",
4628+ "windows_x86_64_gnullvm",
4629+ "windows_x86_64_msvc",
4630+]
4631+
4632+[[package]]
46414633 name = "windows-threading"
46424634 version = "0.2.1"
46434635 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 3 unchanged lines
46474639 ]
46484640
46494641 [[package]]
4642+name = "windows_aarch64_gnullvm"
4643+version = "0.52.6"
4644+source = "registry+https://github.com/rust-lang/crates.io-index"
4645+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
4646+
4647+[[package]]
4648+name = "windows_aarch64_msvc"
4649+version = "0.52.6"
4650+source = "registry+https://github.com/rust-lang/crates.io-index"
4651+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
4652+
4653+[[package]]
4654+name = "windows_i686_gnu"
4655+version = "0.52.6"
4656+source = "registry+https://github.com/rust-lang/crates.io-index"
4657+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
4658+
4659+[[package]]
4660+name = "windows_i686_gnullvm"
4661+version = "0.52.6"
4662+source = "registry+https://github.com/rust-lang/crates.io-index"
4663+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
4664+
4665+[[package]]
4666+name = "windows_i686_msvc"
4667+version = "0.52.6"
4668+source = "registry+https://github.com/rust-lang/crates.io-index"
4669+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
4670+
4671+[[package]]
4672+name = "windows_x86_64_gnu"
4673+version = "0.52.6"
4674+source = "registry+https://github.com/rust-lang/crates.io-index"
4675+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
4676+
4677+[[package]]
4678+name = "windows_x86_64_gnullvm"
4679+version = "0.52.6"
4680+source = "registry+https://github.com/rust-lang/crates.io-index"
4681+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
4682+
4683+[[package]]
4684+name = "windows_x86_64_msvc"
4685+version = "0.52.6"
4686+source = "registry+https://github.com/rust-lang/crates.io-index"
4687+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
4688+
4689+[[package]]
46504690 name = "winnow"
46514691 version = "0.7.15"
46524692 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 208 unchanged lines
modifiedCargo.toml+5 −2
⋯ 30 unchanged lines
3131 maud = { version = "0.27", features = ["axum"] }
3232 rand = "0.10"
3333 serde = { version = "1", features = ["derive"] }
34+serde_yaml = "0.9"
3435 similar = "2"
3536 syntect = { version = "5", default-features = false, features = ["default-fancy"] }
3637 thiserror = "2"
⋯ 7 unchanged lines
4445 tracing = "0.1"
4546 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
4647
47-# ssh
48-russh = "0.61"
48+# ssh — use the `ring` crypto backend instead of the default `aws-lc-rs`:
49+# ring is far cheaper to compile (no cmake/perl) and cross-compiles cleanly
50+# (zigbuild/musl), which matters for building images for the low-RAM VPS.
51+russh = { version = "0.61", default-features = false, features = ["flate2", "ring", "rsa"] }
4952 # ssh-key parsing/fingerprinting, shared by anvil-core (storage) and anvil-ssh
5053 # (auth). Pinned to match russh's transitive ssh-key so fingerprints agree.
5154 ssh-key = "0.7.0-rc.10"
modifiedcrates/anvil-core/Cargo.toml+1 −0
⋯ 12 unchanged lines
1313 argon2.workspace = true
1414 ssh-key.workspace = true
1515 serde.workspace = true
16+serde_yaml.workspace = true
1617 toml.workspace = true
1718 thiserror.workspace = true
1819 tracing.workspace = true
⋯ 4 unchanged lines
addedcrates/anvil-core/src/ci.rs+209 −0
1+//! Continuous integration: the pipeline definition (`.anvil/ci.toml`) and the
2+//! persistence/lifecycle of CI runs. Execution (Docker) lives in `anvil-ci`.
3+
4+use serde::Deserialize;
5+
6+use crate::error::{Error, Result};
7+use crate::models::CiRun;
8+
9+/// Run status values stored in [`CiRun::status`].
10+pub mod status {
11+ pub const QUEUED: &str = "queued";
12+ pub const RUNNING: &str = "running";
13+ pub const SUCCESS: &str = "success";
14+ pub const FAILURE: &str = "failure";
15+ pub const ERROR: &str = "error";
16+}
17+
18+/// Path of the pipeline definition within a repository.
19+pub const PIPELINE_PATH: &str = ".anvil/ci.yml";
20+
21+/// A parsed pipeline: a base image and ordered straight-line steps.
22+#[derive(Debug, Clone, Deserialize)]
23+pub struct Pipeline {
24+ /// Docker image the steps run in, e.g. `rust:1.95-bookworm`.
25+ pub image: String,
26+ #[serde(default)]
27+ pub steps: Vec<Step>,
28+}
29+
30+/// One pipeline step: a shell command, with an optional display name.
31+#[derive(Debug, Clone, Deserialize)]
32+pub struct Step {
33+ #[serde(default)]
34+ pub name: String,
35+ pub run: String,
36+}
37+
38+impl Step {
39+ /// Display label: the explicit name, or the command if unnamed.
40+ pub fn label(&self) -> &str {
41+ if self.name.is_empty() {
42+ &self.run
43+ } else {
44+ &self.name
45+ }
46+ }
47+}
48+
49+/// Parse a `.anvil/ci.yml` pipeline definition.
50+pub fn parse_pipeline(yaml: &str) -> Result<Pipeline> {
51+ let pipeline: Pipeline = serde_yaml::from_str(yaml)
52+ .map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?;
53+ if pipeline.image.trim().is_empty() {
54+ return Err(Error::Invalid(format!("{PIPELINE_PATH}: `image` is required")));
55+ }
56+ Ok(pipeline)
57+}
58+
59+/// Create a queued CI run for a pushed commit.
60+pub async fn enqueue(
61+ db: &toasty::Db,
62+ repo_id: i64,
63+ commit: &str,
64+ ref_name: &str,
65+) -> Result<CiRun> {
66+ let mut conn = db.clone();
67+ let run = toasty::create!(CiRun {
68+ repo_id: repo_id,
69+ commit: commit,
70+ ref_name: ref_name,
71+ status: status::QUEUED,
72+ log: "",
73+ created_at: crate::now(),
74+ started_at: 0,
75+ finished_at: 0,
76+ })
77+ .exec(&mut conn)
78+ .await?;
79+ Ok(run)
80+}
81+
82+/// Fetch a run by id.
83+pub async fn get(db: &toasty::Db, id: i64) -> Result<Option<CiRun>> {
84+ let mut conn = db.clone();
85+ Ok(CiRun::filter(CiRun::fields().id().eq(id))
86+ .first()
87+ .exec(&mut conn)
88+ .await?)
89+}
90+
91+/// List a repository's runs, newest first, up to `limit`.
92+pub async fn list_by_repo(db: &toasty::Db, repo_id: i64, limit: usize) -> Result<Vec<CiRun>> {
93+ let mut conn = db.clone();
94+ let mut runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
95+ .exec(&mut conn)
96+ .await?;
97+ runs.sort_by(|a, b| b.id.cmp(&a.id));
98+ runs.truncate(limit);
99+ Ok(runs)
100+}
101+
102+/// The most recent run for a specific commit (for status badges).
103+pub async fn latest_for_commit(
104+ db: &toasty::Db,
105+ repo_id: i64,
106+ commit: &str,
107+) -> Result<Option<CiRun>> {
108+ let mut conn = db.clone();
109+ let mut runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
110+ .exec(&mut conn)
111+ .await?;
112+ runs.retain(|r| r.commit == commit);
113+ runs.sort_by(|a, b| b.id.cmp(&a.id));
114+ Ok(runs.into_iter().next())
115+}
116+
117+/// Mark a run as started (running).
118+pub async fn mark_running(db: &toasty::Db, id: i64) -> Result<()> {
119+ let Some(mut run) = get(db, id).await? else {
120+ return Ok(());
121+ };
122+ let mut conn = db.clone();
123+ run.update()
124+ .status(status::RUNNING)
125+ .started_at(crate::now())
126+ .exec(&mut conn)
127+ .await?;
128+ Ok(())
129+}
130+
131+/// Append a chunk to a run's log.
132+pub async fn append_log(db: &toasty::Db, id: i64, chunk: &str) -> Result<()> {
133+ let Some(mut run) = get(db, id).await? else {
134+ return Ok(());
135+ };
136+ let combined = format!("{}{chunk}", run.log);
137+ let mut conn = db.clone();
138+ run.update().log(&combined).exec(&mut conn).await?;
139+ Ok(())
140+}
141+
142+/// Finish a run with a terminal status (`success`/`failure`/`error`).
143+pub async fn finish(db: &toasty::Db, id: i64, status: &str) -> Result<()> {
144+ let Some(mut run) = get(db, id).await? else {
145+ return Ok(());
146+ };
147+ let mut conn = db.clone();
148+ run.update()
149+ .status(status)
150+ .finished_at(crate::now())
151+ .exec(&mut conn)
152+ .await?;
153+ Ok(())
154+}
155+
156+/// Re-queue runs left mid-flight by a crash/restart (status `running`).
157+/// Returns the ids that were requeued so the runner can pick them up.
158+pub async fn requeue_interrupted(db: &toasty::Db) -> Result<Vec<i64>> {
159+ let mut conn = db.clone();
160+ let interrupted = CiRun::filter(CiRun::fields().status().eq(status::RUNNING))
161+ .exec(&mut conn)
162+ .await?;
163+ let mut ids = Vec::new();
164+ for mut run in interrupted {
165+ let mut conn = db.clone();
166+ run.update().status(status::QUEUED).exec(&mut conn).await?;
167+ ids.push(run.id);
168+ }
169+ Ok(ids)
170+}
171+
172+/// List all queued run ids (oldest first) — used on startup to drain the queue.
173+pub async fn queued_ids(db: &toasty::Db) -> Result<Vec<i64>> {
174+ let mut conn = db.clone();
175+ let mut runs = CiRun::filter(CiRun::fields().status().eq(status::QUEUED))
176+ .exec(&mut conn)
177+ .await?;
178+ runs.sort_by(|a, b| a.id.cmp(&b.id));
179+ Ok(runs.into_iter().map(|r| r.id).collect())
180+}
181+
182+#[cfg(test)]
183+mod tests {
184+ use super::*;
185+
186+ #[test]
187+ fn parses_a_basic_pipeline() {
188+ // YAML is indentation-sensitive, so the fixture is flush-left.
189+ let p = parse_pipeline(
190+ r#"image: rust:1.95-bookworm
191+steps:
192+ - name: test
193+ run: cargo test --workspace
194+ - run: cargo build --release
195+"#,
196+ )
197+ .unwrap();
198+ assert_eq!(p.image, "rust:1.95-bookworm");
199+ assert_eq!(p.steps.len(), 2);
200+ assert_eq!(p.steps[0].label(), "test");
201+ // Unnamed step falls back to its command for the label.
202+ assert_eq!(p.steps[1].label(), "cargo build --release");
203+ }
204+
205+ #[test]
206+ fn requires_an_image() {
207+ assert!(parse_pipeline("steps: []\n").is_err());
208+ }
209+}
modifiedcrates/anvil-core/src/db.rs+2 −1
⋯ 3 unchanged lines
44
55 use crate::error::Result;
66 use crate::models::{
7+ CiRun,
78 Repository,
89 Session,
910 SshKey,
⋯ 15 unchanged lines
2526 let url = format!("sqlite:{}", path.display());
2627
2728 let db = toasty::Db::builder()
28- .models(toasty::models!(User, Repository, SshKey, Session))
29+ .models(toasty::models!(User, Repository, SshKey, Session, CiRun))
2930 .connect(&url)
3031 .await?;
3132
⋯ 5 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+2 −0
⋯ 5 unchanged lines
66 //! `anvil-git`) build on top of it.
77
88 pub mod access;
9+pub mod ci;
910 pub mod config;
1011 pub mod db;
1112 pub mod error;
⋯ 10 unchanged lines
2223 Result,
2324 };
2425 pub use models::{
26+ CiRun,
2527 Repository,
2628 Session,
2729 SshKey,
⋯ 33 unchanged lines
modifiedcrates/anvil-core/src/models.rs+24 −0
⋯ 36 unchanged lines
3737 pub created_at: i64,
3838 }
3939
40+/// A CI run for a pushed commit.
41+///
42+/// `status` is one of `queued`, `running`, `success`, `failure` (a step exited
43+/// non-zero), or `error` (the runner itself failed). `started_at`/`finished_at`
44+/// are 0 until they occur.
45+#[derive(Debug, Clone, toasty::Model)]
46+pub struct CiRun {
47+ #[key]
48+ #[auto]
49+ pub id: i64,
50+ #[index]
51+ pub repo_id: i64,
52+ /// Full commit SHA the run is for.
53+ pub commit: String,
54+ /// Short branch name that was pushed (e.g. `main`).
55+ pub ref_name: String,
56+ pub status: String,
57+ /// Accumulated run log.
58+ pub log: String,
59+ pub created_at: i64,
60+ pub started_at: i64,
61+ pub finished_at: i64,
62+}
63+
4064 /// A web login session, keyed by an opaque random token stored in a cookie.
4165 #[derive(Debug, toasty::Model)]
4266 pub struct Session {
⋯ 25 unchanged lines
modifiedcrates/anvil-git/Cargo.toml+1 −0
⋯ 10 unchanged lines
1111 anvil-core.workspace = true
1212 gix.workspace = true
1313 gitserver-core.workspace = true
14+toasty.workspace = true
1415 tokio.workspace = true
1516 thiserror.workspace = true
1617 tracing.workspace = true
modifiedcrates/anvil-git/src/lib.rs+1 −0
⋯ 16 unchanged lines
1717 pub mod error;
1818 pub mod smart_http;
1919 pub mod ssh;
20+pub mod trigger;
2021
2122 pub use error::{
2223 Error,
⋯ 6 unchanged lines
addedcrates/anvil-git/src/trigger.rs+66 −0
1+//! Post-push CI trigger: detect which branches changed on a push and enqueue a
2+//! CI run for any whose new commit defines a pipeline (`.anvil/ci.yml`).
3+//!
4+//! Used by the receive-pack paths in `anvil-web` and `anvil-ssh`: snapshot the
5+//! branch tips *before* the push, then call [`enqueue_ci_for_push`] after it
6+//! succeeds.
7+
8+use std::collections::HashMap;
9+use std::path::Path;
10+
11+/// Local branch short-name → tip commit id (hex).
12+pub type BranchTips = HashMap<String, String>;
13+
14+/// Snapshot current local branch tips. Best-effort — returns empty on error.
15+pub fn snapshot_branches(repo_path: &Path) -> BranchTips {
16+ let mut tips = HashMap::new();
17+ let Ok(repo) = gix::open(repo_path) else {
18+ return tips;
19+ };
20+ let Ok(platform) = repo.references() else {
21+ return tips;
22+ };
23+ let Ok(iter) = platform.local_branches() else {
24+ return tips;
25+ };
26+ for mut branch in iter.flatten() {
27+ let name = branch.name().shorten().to_string();
28+ if let Ok(id) = branch.peel_to_id() {
29+ tips.insert(name, id.detach().to_string());
30+ }
31+ }
32+ tips
33+}
34+
35+/// After a push, enqueue a CI run for each branch whose tip changed (or is new)
36+/// and whose new commit contains a pipeline definition. Returns the enqueued
37+/// run ids (so the caller can notify the runner).
38+pub async fn enqueue_ci_for_push(
39+ db: &toasty::Db,
40+ repo_id: i64,
41+ repo_path: &Path,
42+ before: &BranchTips,
43+) -> Vec<i64> {
44+ let after = snapshot_branches(repo_path);
45+ let mut enqueued = Vec::new();
46+ for (branch, new_tip) in &after {
47+ if before.get(branch).map(String::as_str) == Some(new_tip.as_str()) {
48+ continue; // unchanged branch
49+ }
50+ let has_pipeline = matches!(
51+ crate::browse::read_blob(repo_path, new_tip, anvil_core::ci::PIPELINE_PATH),
52+ Ok(Some(_))
53+ );
54+ if !has_pipeline {
55+ continue;
56+ }
57+ match anvil_core::ci::enqueue(db, repo_id, new_tip, branch).await {
58+ Ok(run) => {
59+ tracing::info!("enqueued CI run {} for {branch}@{new_tip}", run.id);
60+ enqueued.push(run.id);
61+ }
62+ Err(e) => tracing::error!("failed to enqueue CI run: {e}"),
63+ }
64+ }
65+ enqueued
66+}
modifiedcrates/anvil-ssh/src/lib.rs+18 −4
⋯ 180 unchanged lines
181181 return fail(session, channel_id, "unsupported command");
182182 };
183183 let need_write = service == anvil_git::Service::ReceivePack;
184- let path = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await {
185- Ok(path) => path,
184+ let (path, repo_id) = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await
185+ {
186+ Ok(resolved) => resolved,
186187 Err(message) => return fail(session, channel_id, message),
187188 };
188189 let Some(channel) = self.channel.take() else {
⋯ 8 unchanged lines
197198
198199 let protocol_v2 = self.protocol_v2;
199200 let handle = session.handle();
201+ let db = self.app.db.clone();
200202 session.channel_success(channel_id)?;
201203
202204 tokio::spawn(async move {
205+ // For a push, snapshot branch tips before serving so we can detect
206+ // what changed and trigger CI afterward.
207+ let before = (service == anvil_git::Service::ReceivePack)
208+ .then(|| anvil_git::trigger::snapshot_branches(&path));
209+
203210 let stream = channel.into_stream();
204211 let code = match git_ssh::serve(&path, service, protocol_v2, stream).await {
205212 Ok(()) => 0,
⋯ 2 unchanged lines
208215 1
209216 }
210217 };
218+
219+ if code == 0 {
220+ if let Some(before) = before {
221+ anvil_git::trigger::enqueue_ci_for_push(&db, repo_id, &path, &before).await;
222+ }
223+ }
224+
211225 let _ = handle.exit_status_request(channel_id, code).await;
212226 let _ = handle.eof(channel_id).await;
213227 let _ = handle.close(channel_id).await;
⋯ 18 unchanged lines
232246 authed_user: Option<i64>,
233247 rel: &str,
234248 need_write: bool,
235-) -> Result<PathBuf, &'static str> {
249+) -> Result<(PathBuf, i64), &'static str> {
236250 let (owner, repo) = rel
237251 .trim_start_matches('/')
238252 .split_once('/')
⋯ 32 unchanged lines
271285 if !path.exists() {
272286 return Err("repository not found");
273287 }
274- Ok(path)
288+ Ok((path, repo.id))
275289 }
modifiedcrates/anvil-web/src/git_http.rs+6 −1
⋯ 212 unchanged lines
213213 return resp;
214214 }
215215
216+ // Snapshot branch tips before the push so we can detect what changed.
217+ let before = anvil_git::trigger::snapshot_branches(&path);
216218 let reader = std::io::Cursor::new(body.to_vec());
217219 match smart_http::receive_pack(&path, reader).await {
218- Ok(b) => rpc_response(Service::ReceivePack.result_content_type(), Body::from(b)),
220+ Ok(b) => {
221+ anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await;
222+ rpc_response(Service::ReceivePack.result_content_type(), Body::from(b))
223+ }
219224 Err(e) => internal(e),
220225 }
221226 }