forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0

🚀 feat: self-update on green ci and repo workflow

Tahinli/forge

5e0ba268d7e0d7be3118af35410b73ebde2c0531

Some checks failed
Show checks

Author Ahmet Kaan Gümüş ·

Parent: f75cd65

Browse files at this commit

🚀 feat: self-update on green ci and repo workflow

- forge swaps its binary when a push to main passes CI (artifact dist_forge), keeps .prev, exits 75 for systemd restart
- runners update from the server's binary when idle via claim headers
- systemd user units with OnFailure rollback and a .bad list
- .forge/ci.toml runs test and build

44 changed files +1597 -74

.forge/ci.tomladded+32 -0
@@ -0,0 +1,32 @@
1+# CI for forge itself. Format: docs/ci.md, parser: crates/forge-ci/src/config.rs.
2+#
3+# cmake is needed because aws-lc-sys (via aws-lc-rs, pulled in by rustls) builds its C
4+# sources with cmake. The rust image has a C compiler but not cmake.
5+# The workflow has no branch condition, so the build job also runs for pull requests.
6+
7+[on]
8+push = ["main"]
9+pull_request = true
10+manual = true
11+
12+[jobs.test]
13+image = "docker.io/library/rust:1"
14+run = """
15+apt-get update
16+apt-get install -y --no-install-recommends cmake
17+cargo test --workspace
18+"""
19+timeout_minutes = 60
20+
21+[jobs.build]
22+image = "docker.io/library/rust:1"
23+needs = ["test"]
24+run = """
25+apt-get update
26+apt-get install -y --no-install-recommends cmake
27+cargo build --profile dist -p forge-web
28+mkdir -p dist
29+cp target/dist/forge dist/forge
30+"""
31+artifacts = ["dist/forge"]
32+timeout_minutes = 60
Cargo.tomlmodified+4 -0
@@ -114,6 +114,10 @@ similar = "3.2.0"
114114 semver = "1.0.28"
115115 zip = { version = "9.0.3", default-features = false, features = ["deflate"] }
116116
117+[profile.dev.package.sha2]
118+# self-update hashes the ~1 GB debug binary at boot; unoptimized sha2 takes ~26 s.
119+opt-level = 3
120+
117121 [profile.release]
118122 lto = true
119123 codegen-units = 1
config/forge.example.tomlmodified+15 -0
@@ -110,3 +110,18 @@ trusted_proxies = []
110110 # [orgs]
111111 # Who may create organizations: "all" (any signed-in person) or "admin" (instance admins only).
112112 # creation = "all"
113+
114+# Self-update (off by default). When enabled, a successful push run of `repo` on `branch` replaces
115+# the running binary with the artifact of `job` and restarts. Run under systemd with Restart=on-failure,
116+# because the process exits with code 75 after the swap. A sha256 listed in `<exe>.bad` is never installed.
117+# [self_update]
118+# Whether a green run deploys its artifact.
119+# enabled = false
120+# The repo whose runs deploy, as owner/name.
121+# repo = "Tahinli/forge"
122+# The branch whose push runs deploy.
123+# branch = "main"
124+# The CI job that produces the artifact.
125+# job = "build"
126+# The artifact name produced by job. A single file. CI flattens paths, so dist/forge is stored as dist_forge.
127+# artifact = "dist_forge"
crates/forge-core/migrations/0024_ci.sqlmodified+1 -1
@@ -1,6 +1,6 @@
11 -- CI: runners, registration tokens, runs, jobs, artifacts and sealed repo secrets.
22 -- Runner, registration and job tokens are stored as sha256 hex only. Timestamps are RFC 3339
3--- UTC stamps from time.rs. See docs/ci-interfaces.md section 2.
3+-- UTC stamps from time.rs.
44
55 CREATE TABLE ci_runners (
66 id TEXT PRIMARY KEY,
crates/forge-core/migrations/0031_advisories.sqlmodified+1 -1
@@ -1,5 +1,5 @@
11 -- Security advisories (OSV dumps), the dependencies read from each repo's lockfiles, and the
2--- alerts that match them. See docs/wave-c-interfaces.md (A1).
2+-- alerts that match them.
33 CREATE TABLE advisories (
44 id TEXT NOT NULL,
55 ecosystem TEXT NOT NULL,
crates/forge-core/migrations/0034_advisory_notifications.sqlmodified+1 -2
@@ -1,5 +1,4 @@
1--- One inbox row per admin and repo, for security alerts a lockfile scan opened (see
2--- docs/wave-c-interfaces.md, F-A). Alerts are not issues, so they cannot use the `notifications`
1+-- One inbox row per admin and repo, for security alerts a lockfile scan opened. Alerts are not issues, so they cannot use the `notifications`
32 -- table, which points at issues. `new_alerts` counts the alerts opened since the row was last read.
43 CREATE TABLE advisory_notifications (
54 user_id TEXT NOT NULL REFERENCES users(id),
crates/forge-core/src/config.rsmodified+96 -1
@@ -99,6 +99,65 @@ pub struct Config {
9999 /// Organization rules, from the `[orgs]` section.
100100 #[serde(default)]
101101 pub orgs: OrgsConfig,
102+ /// Self-update after a green CI run of the forge's own repo, from the `[self_update]` section.
103+ #[serde(default)]
104+ pub self_update: SelfUpdateConfig,
105+}
106+
107+/// Self-update, from the `[self_update]` section. When enabled, a successful push run of `repo`
108+/// on `branch` replaces the running binary with the `artifact` of `job` and restarts.
109+#[derive(Debug, Clone, Deserialize)]
110+#[serde(deny_unknown_fields, default)]
111+pub struct SelfUpdateConfig {
112+ /// Whether a green run deploys its artifact. Off by default.
113+ pub enabled: bool,
114+ /// The repo whose runs deploy, as `owner/name`.
115+ pub repo: String,
116+ /// The branch whose push runs deploy.
117+ pub branch: String,
118+ /// The CI job that produces the artifact.
119+ pub job: String,
120+ /// The artifact name produced by `job`. A single file. CI flattens paths, so `dist/forge`
121+ /// is stored as `dist_forge`.
122+ pub artifact: String,
123+}
124+
125+impl Default for SelfUpdateConfig {
126+ fn default() -> Self {
127+ SelfUpdateConfig {
128+ enabled: false,
129+ repo: String::new(),
130+ branch: "main".into(),
131+ job: "build".into(),
132+ artifact: "dist_forge".into(),
133+ }
134+ }
135+}
136+
137+impl SelfUpdateConfig {
138+ fn validate(&self) -> Result<()> {
139+ if !self.enabled {
140+ return Ok(());
141+ }
142+ let parts: Vec<&str> = self.repo.split('/').collect();
143+ if parts.len() != 2 || parts.iter().any(|part| part.is_empty()) {
144+ return Err(Error::Config(
145+ "[self_update] repo must be owner/name when enabled".into(),
146+ ));
147+ }
148+ for (field, value) in [
149+ ("branch", &self.branch),
150+ ("job", &self.job),
151+ ("artifact", &self.artifact),
152+ ] {
153+ if value.trim().is_empty() {
154+ return Err(Error::Config(format!(
155+ "[self_update] {field} must not be empty when enabled"
156+ )));
157+ }
158+ }
159+ Ok(())
160+ }
102161 }
103162
104163 /// Organization rules, from the `[orgs]` section.
@@ -299,7 +358,7 @@ impl Default for ReleasesConfig {
299358 }
300359 }
301360
302-/// Scheduled backups, from the `[backup]` section. See `docs/backup-interfaces.md`.
361+/// Scheduled backups, from the `[backup]` section. See `docs/backup.md`.
303362 #[derive(Debug, Clone, Deserialize)]
304363 #[serde(deny_unknown_fields, default)]
305364 pub struct BackupConfig {
@@ -395,6 +454,7 @@ impl Config {
395454 toml::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
396455 config.backup.validate()?;
397456 config.replication.validate()?;
457+ config.self_update.validate()?;
398458 Ok(config)
399459 }
400460 }
@@ -548,6 +608,41 @@ mod tests {
548608 assert!(matches!(Config::load(&path), Err(Error::Config(_))));
549609 }
550610
611+ #[test]
612+ fn self_update_section_defaults_to_disabled() {
613+ let dir = tempfile::tempdir().unwrap();
614+ let path = dir.path().join("forge.toml");
615+ std::fs::write(&path, DEV_DEFAULTS).unwrap();
616+ let cfg = Config::load(&path).unwrap();
617+ assert!(!cfg.self_update.enabled);
618+ assert_eq!(cfg.self_update.branch, "main");
619+ assert_eq!(cfg.self_update.job, "build");
620+ assert_eq!(cfg.self_update.artifact, "dist_forge");
621+ }
622+
623+ #[test]
624+ fn self_update_enabled_requires_owner_name_repo() {
625+ let dir = tempfile::tempdir().unwrap();
626+ let path = dir.path().join("forge.toml");
627+ let base = format!("{DEV_DEFAULTS}\n[self_update]\nenabled = true\n");
628+ for repo in ["", "forge", "a/b/c", "/forge", "owner/"] {
629+ std::fs::write(&path, format!("{base}repo = \"{repo}\"\n")).unwrap();
630+ assert!(Config::load(&path).is_err(), "repo `{repo}` should be refused");
631+ }
632+ std::fs::write(&path, format!("{base}repo = \"Tahinli/forge\"\n")).unwrap();
633+ let cfg = Config::load(&path).unwrap();
634+ assert!(cfg.self_update.enabled);
635+ assert_eq!(cfg.self_update.repo, "Tahinli/forge");
636+ }
637+
638+ #[test]
639+ fn self_update_rejects_unknown_keys() {
640+ let dir = tempfile::tempdir().unwrap();
641+ let path = dir.path().join("forge.toml");
642+ std::fs::write(&path, format!("{DEV_DEFAULTS}\n[self_update]\nbogus = 1\n")).unwrap();
643+ assert!(Config::load(&path).is_err());
644+ }
645+
551646 #[test]
552647 fn backup_section_is_parsed_and_validated() {
553648 let dir = tempfile::tempdir().unwrap();
crates/forge-core/src/lfs_token.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! Short-lived signed tokens for Git LFS transfer actions (see docs/wave-b-interfaces.md).
1+//! Short-lived signed tokens for Git LFS transfer actions.
22 //!
33 //! Token format: `"<expires>.<op>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers
44 //! `"lfs\0" + repo_id + "\0" + op + "\0" + expires`. `op` is `download`, `upload` or `any`.
crates/forge-core/src/model.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! Domain types. Stored shapes are described in docs/m1-interfaces.md.
1+//! Domain types. Stored shapes are described in docs/architecture.md, section 4.
22
33 pub type Id = String;
44
crates/forge-core/src/raw_token.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! Signed tokens for private raw-file URLs (see docs/m2-interfaces.md).
1+//! Signed tokens for private raw-file URLs.
22 //!
33 //! Token format: `"<expires>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers
44 //! `"raw-v1:" + repo_id + ":" + expires`.
crates/forge-git/src/browse.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! In-process repo reads via gix (see docs/m2-interfaces.md).
1+//! In-process repo reads via gix.
22 //!
33 //! Everything here is blocking; callers run it inside `spawn_blocking`. Repositories are opened
44 //! isolated: the environment and user/system git configuration are ignored.
crates/forge-git/src/history.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! git CLI history: log, commit diff, last commits, archive (see docs/m2-interfaces.md).
1+//! git CLI history: log, commit diff, last commits, archive.
22 //! Every commit argument is a validated full hex OID; paths go after `--` as literal pathspecs.
33
44 use std::collections::{HashMap, HashSet};
crates/forge-runner/src/cli.rsmodified+12 -7
@@ -18,8 +18,9 @@ use crate::client::ForgeClient;
1818 use crate::config::{self, ExecutorKind, ImagesConfig, Limits, RunnerConfig, UsernsMode};
1919 use crate::executor::{BuiltinExecutor, Executor, PodmanExecutor};
2020 use crate::oci::ImageStore;
21-use crate::runner::{RunOptions, Runner};
21+use crate::runner::{RunEnd, RunOptions, Runner};
2222 use crate::sandbox::idmap::IdMap;
23+use crate::update;
2324
2425 /// How often unused images are garbage collected, after the one at startup.
2526 const GC_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
@@ -124,7 +125,7 @@ where
124125 };
125126 runtime.block_on(async {
126127 match run(cli).await {
127- Ok(()) => ExitCode::SUCCESS,
128+ Ok(code) => code,
128129 Err(e) => {
129130 eprintln!("{bin_name}: {e}");
130131 ExitCode::FAILURE
@@ -166,7 +167,7 @@ fn hidden_dispatch(args: &[OsString]) -> i32 {
166167 }
167168 }
168169
169-async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
170+async fn run(cli: Cli) -> Result<ExitCode, Box<dyn std::error::Error>> {
170171 match cli.command {
171172 Command::Register {
172173 url,
@@ -205,6 +206,7 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
205206 images: ImagesConfig::default(),
206207 sandbox: config::SandboxConfig::default(),
207208 registries: Vec::new(),
209+ auto_update: true,
208210 };
209211 config::save(&path, &cfg)?;
210212 println!(
@@ -212,7 +214,7 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
212214 resp.runner_id,
213215 path.display()
214216 );
215- Ok(())
217+ Ok(ExitCode::SUCCESS)
216218 }
217219 Command::Run { config: path } => {
218220 let path = match path {
@@ -255,8 +257,11 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
255257 stop.cancel();
256258 }
257259 });
258- runner.run(shutdown).await;
259- Ok(())
260+ match runner.run(shutdown).await {
261+ RunEnd::Stopped => Ok(ExitCode::SUCCESS),
262+ // systemd restarts the runner on this code, and it starts the new binary.
263+ RunEnd::Updated => Ok(ExitCode::from(update::RESTART_EXIT_CODE)),
264+ }
260265 }
261266 Command::Check {
262267 work_dir,
@@ -269,7 +274,7 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
269274 let mode = check_userns_mode(config_path)?;
270275 let helper = std::env::current_exe()?;
271276 if crate::check::run(&helper, &work_dir, mode).await {
272- Ok(())
277+ Ok(ExitCode::SUCCESS)
273278 } else {
274279 Err("some host checks failed, see the report above".into())
275280 }
crates/forge-runner/src/client.rsmodified+70 -5
@@ -11,16 +11,25 @@ use forge_ci::proto::{
1111 RegisterResponse,
1212 };
1313 use reqwest::StatusCode;
14-use reqwest::header::CONTENT_LENGTH;
14+use reqwest::header::{CONTENT_LENGTH, HeaderMap};
1515 use serde::de::DeserializeOwned;
16+use sha2::{Digest, Sha256};
17+use tokio::io::AsyncWriteExt;
1618
1719 /// The claim long-poll is 25s on the forge side, so 35s leaves room for the response.
1820 pub const CLAIM_TIMEOUT: Duration = Duration::from_secs(35);
1921 const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
2022 const LOG_TIMEOUT: Duration = Duration::from_secs(60);
2123 const ARTIFACT_TIMEOUT: Duration = Duration::from_secs(60 * 60);
24+const BINARY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
2225 const HEADER_LOG_OFFSET: &str = "X-Forge-Log-Offset";
2326 const HEADER_LOG_SIZE: &str = "X-Forge-Log-Size";
27+/// Sent on every claim: `<ARCH>-<OS>` of this runner, for example `x86_64-linux`.
28+const HEADER_RUNNER_TARGET: &str = "Forge-Runner-Target";
29+/// On a claim response: the sha256 (hex) of the forge's own binary, if the forge advertises one.
30+const HEADER_BINARY_SHA256: &str = "Forge-Binary-Sha256";
31+/// `GET`: the forge's binary, for a runner that is updating itself.
32+const BINARY_PATH: &str = "/api/v1/ci/runners/binary";
2433
2534 /// Errors from talking to the forge.
2635 #[derive(Debug, thiserror::Error)]
@@ -44,6 +53,15 @@ pub enum LogAck {
4453 Resync(u64),
4554 }
4655
56+/// The forge's answer to a claim.
57+#[derive(Clone, Debug)]
58+pub struct ClaimReply {
59+ /// The job assigned to this runner, or `None` when there is nothing to run (204).
60+ pub job: Option<JobAssignment>,
61+ /// The sha256 (lowercase hex) of the forge's binary, when the response advertises one.
62+ pub binary_sha256: Option<String>,
63+}
64+
4765 /// A client for one forge base URL.
4866 #[derive(Clone, Debug)]
4967 pub struct ForgeClient {
@@ -80,30 +98,67 @@ impl ForgeClient {
8098 json_or_error(resp).await
8199 }
82100
83- /// `POST /api/v1/ci/runners/claim`. Returns `None` on 204 (nothing to run).
101+ /// `POST /api/v1/ci/runners/claim`. A 204 means nothing to run, and the reply has no job.
84102 pub async fn claim(
85103 &self,
86104 runner_secret: &str,
87105 labels: &[String],
88- ) -> Result<Option<JobAssignment>, ClientError> {
106+ ) -> Result<ClaimReply, ClientError> {
89107 let req = ClaimRequest {
90108 labels: labels.to_vec(),
91109 };
110+ let target = format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS);
92111 let resp = self
93112 .http
94113 .post(self.url("/api/v1/ci/runners/claim"))
95114 .bearer_auth(runner_secret)
115+ .header(HEADER_RUNNER_TARGET, target)
96116 .json(&req)
97117 .timeout(CLAIM_TIMEOUT)
98118 .send()
99119 .await?;
120+ let binary_sha256 = advertised_sha256(resp.headers());
100121 match resp.status() {
101- StatusCode::NO_CONTENT => Ok(None),
102- status if status.is_success() => Ok(Some(resp.json().await?)),
122+ StatusCode::NO_CONTENT => Ok(ClaimReply {
123+ job: None,
124+ binary_sha256,
125+ }),
126+ status if status.is_success() => Ok(ClaimReply {
127+ job: Some(resp.json().await?),
128+ binary_sha256,
129+ }),
103130 _ => Err(status_error(resp).await),
104131 }
105132 }
106133
134+ /// `GET /api/v1/ci/runners/binary`. Streams the forge's binary into `dest` and returns the
135+ /// sha256 (lowercase hex) of the bytes written. The caller checks it against the advertised
136+ /// value and syncs the file.
137+ pub async fn download_binary(
138+ &self,
139+ runner_secret: &str,
140+ dest: &Path,
141+ ) -> Result<String, ClientError> {
142+ let mut resp = self
143+ .http
144+ .get(self.url(BINARY_PATH))
145+ .bearer_auth(runner_secret)
146+ .timeout(BINARY_TIMEOUT)
147+ .send()
148+ .await?;
149+ if !resp.status().is_success() {
150+ return Err(status_error(resp).await);
151+ }
152+ let mut file = tokio::fs::File::create(dest).await?;
153+ let mut hasher = Sha256::new();
154+ while let Some(chunk) = resp.chunk().await? {
155+ hasher.update(&chunk);
156+ file.write_all(&chunk).await?;
157+ }
158+ file.flush().await?;
159+ Ok(hex::encode(hasher.finalize()))
160+ }
161+
107162 /// `POST /api/v1/ci/jobs/{id}/logs` with the byte offset of `body` in the job log.
108163 pub async fn post_logs(
109164 &self,
@@ -205,6 +260,16 @@ impl ForgeClient {
205260 }
206261 }
207262
263+/// The `Forge-Binary-Sha256` header, trimmed and lowercased. An empty value counts as absent.
264+fn advertised_sha256(headers: &HeaderMap) -> Option<String> {
265+ headers
266+ .get(HEADER_BINARY_SHA256)?
267+ .to_str()
268+ .ok()
269+ .map(|v| v.trim().to_ascii_lowercase())
270+ .filter(|v| !v.is_empty())
271+}
272+
208273 async fn json_or_error<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T, ClientError> {
209274 if resp.status().is_success() {
210275 Ok(resp.json().await?)
crates/forge-runner/src/config.rsmodified+25 -0
@@ -130,12 +130,21 @@ pub struct RunnerConfig {
130130 pub sandbox: SandboxConfig,
131131 #[serde(default, rename = "registry")]
132132 pub registries: Vec<RegistryConfig>,
133+ /// When true (the default), an idle runner replaces itself with the forge's binary when the
134+ /// forge advertises a different one on the claim response. The runner then exits with code
135+ /// 75 and systemd restarts it. Set `auto_update = false` to pin the binary by hand.
136+ #[serde(default = "default_auto_update")]
137+ pub auto_update: bool,
133138 }
134139
135140 fn default_concurrency() -> usize {
136141 1
137142 }
138143
144+fn default_auto_update() -> bool {
145+ true
146+}
147+
139148 fn default_keep_days() -> u32 {
140149 7
141150 }
@@ -159,6 +168,7 @@ impl fmt::Debug for RunnerConfig {
159168 .field("executor", &self.executor)
160169 .field("images", &self.images)
161170 .field("registries", &self.registries)
171+ .field("auto_update", &self.auto_update)
162172 .finish()
163173 }
164174 }
@@ -268,6 +278,7 @@ mod tests {
268278 images: ImagesConfig::default(),
269279 sandbox: SandboxConfig::default(),
270280 registries: Vec::new(),
281+ auto_update: true,
271282 }
272283 }
273284
@@ -340,6 +351,20 @@ mod tests {
340351 assert_eq!(config.images.keep_days, 7);
341352 assert_eq!(config.limits.image_max_gb, 20);
342353 assert!(config.registries.is_empty());
354+ assert!(config.auto_update);
355+ }
356+
357+ #[test]
358+ fn auto_update_can_be_turned_off() {
359+ let dir = tempfile::tempdir().expect("tempdir");
360+ let path = dir.path().join("runner.toml");
361+ std::fs::write(
362+ &path,
363+ "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\n\
364+ work_dir = \"/w\"\nauto_update = false\n",
365+ )
366+ .expect("write");
367+ assert!(!load(&path).expect("load").auto_update);
343368 }
344369
345370 #[test]
crates/forge-runner/src/executor.rsmodified+1 -1
@@ -519,7 +519,7 @@ impl ImageSource for ImageStore {
519519 }
520520
521521 /// Runs jobs in a libcontainer sandbox, started as `helper` (`forge-runner __sandbox`). It needs
522-/// no external container tool. See `docs/ci-builtin-executor.md`.
522+/// no external container tool. See `docs/ci.md`.
523523 pub struct BuiltinExecutor<I = ImageStore> {
524524 images: I,
525525 limits: Limits,
crates/forge-runner/src/lib.rsmodified+1 -0
@@ -14,4 +14,5 @@ pub mod logs;
1414 pub mod oci;
1515 pub mod runner;
1616 pub mod sandbox;
17+pub mod update;
1718 pub mod workspace;
crates/forge-runner/src/runner.rsmodified+96 -21
@@ -16,6 +16,7 @@ use crate::client::{ClientError, ForgeClient};
1616 use crate::config::RunnerConfig;
1717 use crate::executor::{ExecRequest, ExecResult, Executor};
1818 use crate::logs::{self, PumpConfig};
19+use crate::update::{self, Installed, UpdateState};
1920 use crate::workspace;
2021
2122 /// Log lines queued between the executor and the log shipper.
@@ -46,6 +47,15 @@ impl Default for RunOptions {
4647 }
4748 }
4849
50+/// How [`Runner::run`] ended.
51+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52+pub enum RunEnd {
53+ /// Shutdown was requested.
54+ Stopped,
55+ /// The binary was replaced. The caller exits with [`update::RESTART_EXIT_CODE`].
56+ Updated,
57+}
58+
4959 /// A runner bound to one forge and one executor.
5060 #[derive(Clone)]
5161 pub struct Runner {
@@ -76,50 +86,114 @@ impl Runner {
7686
7787 /// Claims and runs jobs, up to `concurrency` at a time, until `shutdown` is cancelled. Jobs
7888 /// still running at shutdown are cancelled and reported as cancelled.
79- pub async fn run(&self, shutdown: CancellationToken) {
89+ ///
90+ /// When the forge advertises a different binary and `auto_update` is on, no new jobs are
91+ /// claimed. Once the running jobs finish, the binary is replaced and the loop returns
92+ /// [`RunEnd::Updated`].
93+ pub async fn run(&self, shutdown: CancellationToken) -> RunEnd {
8094 let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
8195 let mut jobs: JoinSet<()> = JoinSet::new();
96+ // Hashed once at startup. Without it, or with auto_update off, updates stay disabled.
97+ let exe = if self.config.auto_update {
98+ match update::locate_self() {
99+ Ok(found) => Some(found),
100+ Err(e) => {
101+ tracing::warn!(error = %e, "auto update disabled, cannot read own binary");
102+ None
103+ }
104+ }
105+ } else {
106+ None
107+ };
108+ let mut updates = UpdateState::new(
109+ self.config.auto_update,
110+ exe.as_ref().map(|(_, sha)| sha.clone()),
111+ );
82112 tracing::info!(
83113 name = %self.config.name,
84114 concurrency = self.config.concurrency,
115+ auto_update = updates.enabled(),
85116 "runner started"
86117 );
87- loop {
118+ let end = loop {
119+ if let Some((exe_path, _)) = &exe
120+ && let Some(sha) = updates.pending().map(str::to_owned)
121+ {
122+ if !jobs.is_empty() {
123+ // Claiming is paused until the running jobs finish.
124+ tokio::select! {
125+ _ = shutdown.cancelled() => break RunEnd::Stopped,
126+ Some(joined) = jobs.join_next() => {
127+ if let Err(e) = joined {
128+ tracing::error!(error = %e, "job task panicked");
129+ }
130+ }
131+ }
132+ continue;
133+ }
134+ match update::install(&self.client, &self.config.runner_secret, exe_path, &sha)
135+ .await
136+ {
137+ Ok(Installed::Replaced) => {
138+ tracing::info!(sha = %sha, "binary updated, restarting");
139+ break RunEnd::Updated;
140+ }
141+ Ok(Installed::Listed) => {
142+ tracing::warn!(sha = %sha, "forge binary is on the bad list, not updating");
143+ updates.give_up(&sha);
144+ }
145+ Err(e) => {
146+ tracing::warn!(sha = %sha, error = %e, "update failed, keeping this binary");
147+ updates.give_up(&sha);
148+ }
149+ }
150+ continue;
151+ }
152+
88153 let permit = tokio::select! {
89- _ = shutdown.cancelled() => break,
154+ _ = shutdown.cancelled() => break RunEnd::Stopped,
90155 permit = semaphore.clone().acquire_owned() => match permit {
91156 Ok(permit) => permit,
92- Err(_) => break,
157+ Err(_) => break RunEnd::Stopped,
93158 },
94159 };
95160 let claimed = tokio::select! {
96161 _ = shutdown.cancelled() => {
97162 drop(permit);
98- break;
163+ break RunEnd::Stopped;
99164 }
100165 r = self.client.claim(&self.config.runner_secret, &self.config.labels) => r,
101166 };
102167 match claimed {
103- Ok(Some(job)) => {
104- tracing::info!(
105- job_id = %job.job_id,
106- job_name = %job.job_name,
107- repo = %job.repo,
108- "claimed job"
109- );
110- let runner = self.clone();
111- let cancel = shutdown.child_token();
112- jobs.spawn(async move {
113- let _permit = permit;
114- runner.process(job, cancel).await;
115- });
168+ Ok(reply) => {
169+ if updates.observe(reply.binary_sha256.as_deref())
170+ && let Some(sha) = updates.pending()
171+ {
172+ tracing::info!(sha = %sha, "forge advertises a new binary, updating when idle");
173+ }
174+ match reply.job {
175+ Some(job) => {
176+ tracing::info!(
177+ job_id = %job.job_id,
178+ job_name = %job.job_name,
179+ repo = %job.repo,
180+ "claimed job"
181+ );
182+ let runner = self.clone();
183+ let cancel = shutdown.child_token();
184+ jobs.spawn(async move {
185+ let _permit = permit;
186+ runner.process(job, cancel).await;
187+ });
188+ }
189+ None => drop(permit),
190+ }
116191 }
117- Ok(None) => drop(permit),
118192 Err(e) => {
119193 drop(permit);
120194 tracing::warn!(error = %e, "claim failed, backing off");
121195 tokio::select! {
122- _ = shutdown.cancelled() => break,
196+ _ = shutdown.cancelled() => break RunEnd::Stopped,
123197 _ = tokio::time::sleep(self.options.claim_backoff) => {}
124198 }
125199 }
@@ -129,13 +203,14 @@ impl Runner {
129203 tracing::error!(error = %e, "job task panicked");
130204 }
131205 }
132- }
206+ };
133207 while let Some(joined) = jobs.join_next().await {
134208 if let Err(e) = joined {
135209 tracing::error!(error = %e, "job task panicked");
136210 }
137211 }
138212 tracing::info!("runner stopped");
213+ end
139214 }
140215
141216 /// Runs one claimed job end to end and reports its result. Returns the final outcome.
crates/forge-runner/src/sandbox/mod.rsmodified+1 -1
@@ -3,7 +3,7 @@
33 //! The runner never calls libcontainer itself. libcontainer forks and clones, which is unsafe in
44 //! a multi-threaded tokio process, so the runner spawns `forge-runner __sandbox` as a
55 //! single-threaded child. That child reads a [`SandboxRequest`] on stdin and runs the job. See
6-//! `docs/ci-builtin-executor.md`.
6+//! `docs/ci.md`.
77
88 pub mod exec;
99 pub mod helper;
crates/forge-runner/src/update.rsadded+330 -0
@@ -0,0 +1,330 @@
1+//! Self-update for an idle runner. Each claim response may advertise the sha256 of the forge's
2+//! binary. When that differs from ours, the runner stops claiming, lets its running jobs finish,
3+//! swaps its own binary for the forge's, and exits with [`RESTART_EXIT_CODE`] so systemd starts
4+//! the new one.
5+//!
6+//! [`UpdateState`] makes the decisions and does no I/O. [`install`] does the file work.
7+
8+use std::io::Read;
9+use std::os::unix::fs::PermissionsExt;
10+use std::path::{Path, PathBuf};
11+use std::process::Stdio;
12+use std::time::Duration;
13+
14+use sha2::{Digest, Sha256};
15+
16+use crate::client::{ClientError, ForgeClient};
17+
18+/// The exit code after a successful swap. systemd restarts the runner on any non-zero code.
19+pub const RESTART_EXIT_CODE: u8 = 75;
20+/// How long the new binary gets to answer `--version`.
21+const SMOKE_TIMEOUT: Duration = Duration::from_secs(10);
22+/// The temp file, next to the running binary, that the download goes to.
23+const NEW_NAME: &str = ".forge-runner.new";
24+/// The previous binary, kept for a manual rollback.
25+const PREV_SUFFIX: &str = ".prev";
26+/// Optional list of bad binaries, one sha256 per line, next to the running binary.
27+const BAD_SUFFIX: &str = ".bad";
28+
29+/// Decides whether an update is due. It remembers the last sha that failed, so the runner does
30+/// not retry it on every claim. A different advertised sha clears that memory.
31+#[derive(Clone, Debug, PartialEq, Eq)]
32+pub struct UpdateState {
33+ enabled: bool,
34+ /// The sha256 of the running binary. Updates are disabled when it is unknown.
35+ current: Option<String>,
36+ pending: Option<String>,
37+ given_up: Option<String>,
38+}
39+
40+impl UpdateState {
41+ /// `current` is the sha256 (lowercase hex) of the running binary.
42+ pub fn new(auto_update: bool, current: Option<String>) -> Self {
43+ Self {
44+ enabled: auto_update && current.is_some(),
45+ current,
46+ pending: None,
47+ given_up: None,
48+ }
49+ }
50+
51+ /// Records the sha the forge advertised on a claim response. Returns true when this makes a
52+ /// new update due. An absent header changes nothing.
53+ pub fn observe(&mut self, advertised: Option<&str>) -> bool {
54+ if !self.enabled {
55+ return false;
56+ }
57+ let Some(advertised) = advertised else {
58+ return false;
59+ };
60+ if Some(advertised) == self.current.as_deref()
61+ || Some(advertised) == self.given_up.as_deref()
62+ {
63+ // Already running it, or it failed and the forge has not moved on.
64+ self.pending = None;
65+ return false;
66+ }
67+ if self.pending.as_deref() == Some(advertised) {
68+ return false;
69+ }
70+ self.pending = Some(advertised.to_owned());
71+ true
72+ }
73+
74+ /// Whether updates are on at all. False when `auto_update` is off or the binary is unknown.
75+ pub fn enabled(&self) -> bool {
76+ self.enabled
77+ }
78+
79+ /// The sha to update to, if an update is due.
80+ pub fn pending(&self) -> Option<&str> {
81+ self.pending.as_deref()
82+ }
83+
84+ /// Stops trying `sha` until the forge advertises a different one.
85+ pub fn give_up(&mut self, sha: &str) {
86+ self.given_up = Some(sha.to_owned());
87+ self.pending = None;
88+ }
89+}
90+
91+/// Whether a `.bad` list, one sha256 per line, names `sha`.
92+pub fn is_listed_bad(list: &str, sha: &str) -> bool {
93+ list.lines()
94+ .any(|line| line.trim().eq_ignore_ascii_case(sha))
95+}
96+
97+/// The sha256 (lowercase hex) of the file at `path`.
98+pub fn file_sha256(path: &Path) -> std::io::Result<String> {
99+ let mut file = std::fs::File::open(path)?;
100+ let mut hasher = Sha256::new();
101+ let mut buf = vec![0u8; 64 * 1024];
102+ loop {
103+ let n = file.read(&mut buf)?;
104+ if n == 0 {
105+ break;
106+ }
107+ hasher.update(&buf[..n]);
108+ }
109+ Ok(hex::encode(hasher.finalize()))
110+}
111+
112+/// The path of the running binary, and the sha256 of its contents.
113+pub fn locate_self() -> std::io::Result<(PathBuf, String)> {
114+ let exe = std::env::current_exe()?;
115+ let sha = file_sha256(&exe)?;
116+ Ok((exe, sha))
117+}
118+
119+/// What [`install`] did when it did not fail.
120+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121+pub enum Installed {
122+ /// The new binary is in place. The caller should exit with [`RESTART_EXIT_CODE`].
123+ Replaced,
124+ /// The `.bad` list names the advertised binary, so nothing was replaced.
125+ Listed,
126+}
127+
128+/// Why an update failed. The current binary is untouched in every case.
129+#[derive(Debug, thiserror::Error)]
130+pub enum UpdateError {
131+ #[error("local io: {0}")]
132+ Io(#[from] std::io::Error),
133+ #[error("download: {0}")]
134+ Download(#[from] ClientError),
135+ #[error("sha256 mismatch: forge advertised {expected}, downloaded {actual}")]
136+ Mismatch { expected: String, actual: String },
137+ #[error("new binary failed its check: {0}")]
138+ Smoke(String),
139+}
140+
141+/// Downloads the forge's binary next to `exe`, checks it, and swaps it in. The old binary is kept
142+/// as `<exe>.prev`. On any error the temp file is removed.
143+pub async fn install(
144+ client: &ForgeClient,
145+ runner_secret: &str,
146+ exe: &Path,
147+ expected: &str,
148+) -> Result<Installed, UpdateError> {
149+ let new = exe.with_file_name(NEW_NAME);
150+ // A temp file left by an earlier crash must not be reused.
151+ let _ = std::fs::remove_file(&new);
152+ let result = stage_and_swap(client, runner_secret, exe, &new, expected).await;
153+ if !matches!(result, Ok(Installed::Replaced)) {
154+ let _ = std::fs::remove_file(&new);
155+ }
156+ result
157+}
158+
159+async fn stage_and_swap(
160+ client: &ForgeClient,
161+ runner_secret: &str,
162+ exe: &Path,
163+ new: &Path,
164+ expected: &str,
165+) -> Result<Installed, UpdateError> {
166+ let actual = client.download_binary(runner_secret, new).await?;
167+ if actual != expected {
168+ return Err(UpdateError::Mismatch {
169+ expected: expected.to_owned(),
170+ actual,
171+ });
172+ }
173+ std::fs::set_permissions(new, std::fs::Permissions::from_mode(0o755))?;
174+ std::fs::File::open(new)?.sync_all()?;
175+ smoke_test(new).await?;
176+
177+ let bad = match std::fs::read_to_string(sibling(exe, BAD_SUFFIX)) {
178+ Ok(list) => list,
179+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
180+ Err(e) => return Err(e.into()),
181+ };
182+ if is_listed_bad(&bad, expected) {
183+ return Ok(Installed::Listed);
184+ }
185+
186+ let prev = sibling(exe, PREV_SUFFIX);
187+ match std::fs::remove_file(&prev) {
188+ Ok(()) => {}
189+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
190+ Err(e) => return Err(e.into()),
191+ }
192+ // A hard link keeps the old inode alive at no cost. Copy only where links are not allowed.
193+ if std::fs::hard_link(exe, &prev).is_err() {
194+ std::fs::copy(exe, &prev)?;
195+ }
196+ std::fs::rename(new, exe)?;
197+ Ok(Installed::Replaced)
198+}
199+
200+/// Runs `<bin> --version` and requires exit 0 within [`SMOKE_TIMEOUT`].
201+async fn smoke_test(bin: &Path) -> Result<(), UpdateError> {
202+ let output = tokio::time::timeout(
203+ SMOKE_TIMEOUT,
204+ tokio::process::Command::new(bin)
205+ .arg("--version")
206+ .stdin(Stdio::null())
207+ .kill_on_drop(true)
208+ .output(),
209+ )
210+ .await
211+ .map_err(|_| UpdateError::Smoke("--version did not finish in time".into()))??;
212+ if !output.status.success() {
213+ return Err(UpdateError::Smoke(format!(
214+ "--version exited with {}",
215+ output.status
216+ )));
217+ }
218+ Ok(())
219+}
220+
221+/// `exe` with `suffix` appended to its file name, for example `forge.prev`.
222+fn sibling(exe: &Path, suffix: &str) -> PathBuf {
223+ let mut name = exe.as_os_str().to_owned();
224+ name.push(suffix);
225+ PathBuf::from(name)
226+}
227+
228+#[cfg(test)]
229+mod tests {
230+ use super::*;
231+
232+ const OURS: &str = "aaaa";
233+ const THEIRS: &str = "bbbb";
234+
235+ fn state(auto_update: bool) -> UpdateState {
236+ UpdateState::new(auto_update, Some(OURS.to_owned()))
237+ }
238+
239+ #[test]
240+ fn a_different_sha_becomes_pending_once() {
241+ let mut s = state(true);
242+ assert!(s.observe(Some(THEIRS)));
243+ assert_eq!(s.pending(), Some(THEIRS));
244+ // The same sha again is not news.
245+ assert!(!s.observe(Some(THEIRS)));
246+ assert_eq!(s.pending(), Some(THEIRS));
247+ }
248+
249+ #[test]
250+ fn matching_sha_or_no_header_leaves_nothing_pending() {
251+ let mut s = state(true);
252+ assert!(!s.observe(Some(OURS)));
253+ assert_eq!(s.pending(), None);
254+ assert!(!s.observe(None));
255+ assert_eq!(s.pending(), None);
256+ }
257+
258+ #[test]
259+ fn absent_header_keeps_a_pending_update() {
260+ let mut s = state(true);
261+ s.observe(Some(THEIRS));
262+ s.observe(None);
263+ assert_eq!(s.pending(), Some(THEIRS));
264+ }
265+
266+ #[test]
267+ fn disabled_or_unknown_current_never_pends() {
268+ let mut off = state(false);
269+ assert!(!off.observe(Some(THEIRS)));
270+ assert_eq!(off.pending(), None);
271+
272+ let mut unknown = UpdateState::new(true, None);
273+ assert!(!unknown.observe(Some(THEIRS)));
274+ assert_eq!(unknown.pending(), None);
275+ }
276+
277+ #[test]
278+ fn a_failed_sha_is_not_retried_until_the_forge_moves_on() {
279+ let mut s = state(true);
280+ s.observe(Some(THEIRS));
281+ s.give_up(THEIRS);
282+ assert_eq!(s.pending(), None);
283+
284+ // Claims keep advertising the same sha: nothing becomes pending.
285+ assert!(!s.observe(Some(THEIRS)));
286+ assert_eq!(s.pending(), None);
287+
288+ // A different sha is due again.
289+ assert!(s.observe(Some("cccc")));
290+ assert_eq!(s.pending(), Some("cccc"));
291+ }
292+
293+ #[test]
294+ fn going_back_to_the_failed_sha_cancels_a_pending_update() {
295+ let mut s = state(true);
296+ s.observe(Some(THEIRS));
297+ s.give_up(THEIRS);
298+ s.observe(Some("cccc"));
299+ s.observe(Some(THEIRS));
300+ assert_eq!(s.pending(), None);
301+ }
302+
303+ #[test]
304+ fn bad_list_matches_whole_lines_case_insensitively() {
305+ let list = "# comment is not a sha\nBBBB\n cccc \n";
306+ assert!(is_listed_bad(list, THEIRS));
307+ assert!(is_listed_bad(list, "cccc"));
308+ assert!(!is_listed_bad(list, "bbb"));
309+ assert!(!is_listed_bad("", THEIRS));
310+ }
311+
312+ #[test]
313+ fn sibling_appends_the_suffix() {
314+ assert_eq!(
315+ sibling(Path::new("/home/t/.local/bin/forge"), PREV_SUFFIX),
316+ PathBuf::from("/home/t/.local/bin/forge.prev")
317+ );
318+ }
319+
320+ #[test]
321+ fn file_sha256_matches_a_known_digest() {
322+ let dir = tempfile::tempdir().expect("tempdir");
323+ let path = dir.path().join("f");
324+ std::fs::write(&path, b"abc").expect("write");
325+ assert_eq!(
326+ file_sha256(&path).expect("hash"),
327+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
328+ );
329+ }
330+}
crates/forge-runner/tests/flow.rsmodified+3 -2
@@ -16,7 +16,7 @@ use forge_ci::proto::{FinishRequest, JobAssignment, Outcome, RegisterResponse};
1616 use forge_runner::config::{Limits, RunnerConfig};
1717 use forge_runner::executor::HostExecutor;
1818 use forge_runner::logs::PumpConfig;
19-use forge_runner::runner::{RunOptions, Runner};
19+use forge_runner::runner::{RunEnd, RunOptions, Runner};
2020 use http_body_util::{BodyExt, Full};
2121 use hyper::body::Incoming;
2222 use hyper::header::{AUTHORIZATION, HeaderMap};
@@ -321,6 +321,7 @@ fn config(url: &str, work_dir: PathBuf) -> RunnerConfig {
321321 images: forge_runner::config::ImagesConfig::default(),
322322 sandbox: forge_runner::config::SandboxConfig::default(),
323323 registries: Vec::new(),
324+ auto_update: true,
324325 }
325326 }
326327
@@ -349,7 +350,7 @@ async fn wait_until(what: &str, mut check: impl FnMut() -> bool) {
349350 struct Harness {
350351 state: Shared,
351352 stop: CancellationToken,
352- runner_task: tokio::task::JoinHandle<()>,
353+ runner_task: tokio::task::JoinHandle<RunEnd>,
353354 _dir: tempfile::TempDir,
354355 }
355356
crates/forge-web/src/backup/mod.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! Backup sets (phase 1, see `docs/backup-interfaces.md`).
1+//! Backup sets (phase 1, see `docs/backup.md`).
22 //!
33 //! A set is a directory `forge-backup-<UTC stamp>/` holding a consistent DB snapshot, one git
44 //! bundle per non-empty repo, optionally the instance key, and a `manifest.json` with a sha256 for
crates/forge-web/src/boot.rsmodified+21 -2
@@ -55,6 +55,13 @@ pub async fn serve(config: Config) -> forge_core::Result<()> {
5555 let forge_bin = std::env::current_exe()?.canonicalize()?;
5656 forge_git::setup::write_hook_shims(&data_dir, &forge_bin)?;
5757
58+ // Hash the running binary once, before any runner can ask for it. Hashing reads the whole
59+ // executable, which takes tens of seconds for a debug build, so it runs off the boot path and
60+ // the listeners bind first. Any caller that needs the hash waits on the same `OnceLock`.
61+ tokio::task::spawn_blocking(|| {
62+ crate::self_update::running();
63+ });
64+
5865 let internal_token: Arc<str> = Arc::from(random_hex());
5966 let admin_token: Arc<str> = Arc::from(random_hex());
6067 crate::admin_client::write_admin_token(&data_dir, &admin_token)?;
@@ -99,6 +106,8 @@ pub async fn serve(config: Config) -> forge_core::Result<()> {
99106 ci_waker: Arc::new(Notify::new()),
100107 mailer,
101108 mail_waker: Arc::new(Notify::new()),
109+ notify_hub: Arc::new(crate::notify_hub::NotifyHub::new()),
110+ ci_live: Arc::new(crate::ci::CiLive::default()),
102111 };
103112 if let Err(e) = crate::signatures::rebuild_allowed_signers(&state).await {
104113 tracing::error!(error = %e, "building the SSH allowed-signers file failed");
@@ -150,6 +159,14 @@ pub async fn serve(config: Config) -> forge_core::Result<()> {
150159
151160 let (stop_tx, stop_rx) = watch::channel(false);
152161
162+ // Open notification streams never end on their own, so they are closed with the rest.
163+ let hub = Arc::clone(&state.notify_hub);
164+ let hub_stop = stop_rx.clone();
165+ tokio::spawn(async move {
166+ wait_stop(hub_stop).await;
167+ hub.close();
168+ });
169+
153170 let ssh_config = SshConfig {
154171 listen: ssh_listen,
155172 host_key_path: data_dir.join("ssh_host_ed25519_key"),
@@ -169,8 +186,10 @@ pub async fn serve(config: Config) -> forge_core::Result<()> {
169186
170187 let signal_stop = stop_tx.clone();
171188 tokio::spawn(async move {
172- shutdown_signal().await;
173- tracing::info!("shutdown signal received");
189+ tokio::select! {
190+ () = shutdown_signal() => tracing::info!("shutdown signal received"),
191+ () = crate::self_update::restart_requested() => tracing::info!("self-update: restarting"),
192+ }
174193 signal_stop.send_replace(true);
175194 });
176195
crates/forge-web/src/ci/api.rsmodified+66 -16
@@ -1,4 +1,4 @@
1-//! Runner and job API under `/api/v1/ci` (contract: docs/ci-interfaces.md, section 4).
1+//! Runner and job API under `/api/v1/ci` (see docs/ci.md, Runner protocol).
22 //!
33 //! Runners authenticate with `Authorization: Bearer <runner secret>`. A job's routes take
44 //! `Authorization: Bearer <job token>`, which `job_by_token` accepts only while the job is Running.
@@ -25,6 +25,7 @@ use topcoat::router::{Body, HeaderMap, StatusCode, path_param, route};
2525 use crate::api::{self, Refusal};
2626 use crate::ci::{artifact_key, log_path};
2727 use crate::pages::repo::https_clone_url;
28+use crate::self_update;
2829 use crate::state::AppState;
2930
3031 path_param!(job_id);
@@ -170,6 +171,13 @@ async fn register(cx: &Cx, body: Body) -> topcoat::Result<Response> {
170171
171172 #[route(POST "/api/v1/ci/runners/claim")]
172173 async fn claim(cx: &Cx) -> topcoat::Result<Response> {
174+ let mut res = claim_work(cx).await?;
175+ // Advertises the binary sha256 when self-update is on and the runner asked for this target.
176+ self_update::advertise(app_context::<AppState>(cx), headers(cx), &mut res);
177+ Ok(res)
178+}
179+
180+async fn claim_work(cx: &Cx) -> topcoat::Result<Response> {
173181 let state = app_context::<AppState>(cx);
174182 // The labels in the request are ignored: the labels stored at registration are used.
175183 let runner = match runner_from(state, headers(cx)).await {
@@ -177,24 +185,48 @@ async fn claim(cx: &Cx) -> topcoat::Result<Response> {
177185 Err(res) => return Ok(*res),
178186 };
179187
180- // Register for the wake-up before the first attempt, so a job queued in between is not missed.
188+ // Arm the wake-up before each attempt, so a job queued during the attempt is not missed.
189+ // Producers use notify_waiters, which wakes every claimer, so a runner whose labels
190+ // don't match a job re-arms and keeps waiting for the rest of CLAIM_WAIT.
181191 let waker = state.ci_waker.clone();
182- let mut woken = std::pin::pin!(waker.notified());
183- woken.as_mut().enable();
192+ let deadline = tokio::time::Instant::now() + CLAIM_WAIT;
193+ loop {
194+ let mut woken = std::pin::pin!(waker.notified());
195+ woken.as_mut().enable();
196+
197+ match try_claim(state, &runner).await {
198+ Ok(Some(assignment)) => return Ok(assignment_response(&assignment)),
199+ Ok(None) => {}
200+ Err(res) => return Ok(*res),
201+ }
202+ if tokio::time::timeout_at(deadline, woken.as_mut())
203+ .await
204+ .is_err()
205+ {
206+ return Ok(empty(StatusCode::NO_CONTENT));
207+ }
208+ }
209+}
184210
185- match try_claim(state, &runner).await {
186- Ok(Some(assignment)) => return Ok(assignment_response(&assignment)),
187- Ok(None) => {}
188- Err(res) => return Ok(*res),
211+/// The running binary, for a runner that updates itself. 404 unless self-update is enabled.
212+#[route(GET "/api/v1/ci/runners/binary")]
213+async fn runner_binary(cx: &Cx) -> topcoat::Result<Response> {
214+ let state = app_context::<AppState>(cx);
215+ if let Err(res) = runner_from(state, headers(cx)).await {
216+ return Ok(*res);
189217 }
190- tokio::select! {
191- _ = woken.as_mut() => {}
192- _ = tokio::time::sleep(CLAIM_WAIT) => {}
218+ if !state.config.self_update.enabled {
219+ return Ok(api::error(StatusCode::NOT_FOUND, "self-update is disabled"));
193220 }
194- match try_claim(state, &runner).await {
195- Ok(Some(assignment)) => Ok(assignment_response(&assignment)),
196- Ok(None) => Ok(empty(StatusCode::NO_CONTENT)),
197- Err(res) => Ok(*res),
221+ match self_update::binary_response().await {
222+ Ok(res) => Ok(res),
223+ Err(e) => {
224+ tracing::error!(error = %e, "ci: serving the runner binary failed");
225+ Ok(api::error(
226+ StatusCode::INTERNAL_SERVER_ERROR,
227+ "the binary is unavailable",
228+ ))
229+ }
198230 }
199231 }
200232
@@ -217,6 +249,8 @@ async fn try_claim(state: &AppState, runner: &CiRunner) -> Result<Option<JobAssi
217249 else {
218250 return Ok(None);
219251 };
252+ state.ci_live.bump_job(&job.id);
253+ state.ci_live.bump_run(&run.id);
220254 let spec: JobSpec = serde_json::from_str(&job.spec_json).map_err(api::internal)?;
221255 let repo = state
222256 .store
@@ -370,6 +404,7 @@ async fn write_log(
370404 .add_log_bytes(&job.id, len as i64)
371405 .await
372406 .map_err(api::internal)?;
407+ state.ci_live.bump_job(&job.id);
373408 Ok(LogWrite::Stored(size + len))
374409 }
375410
@@ -591,7 +626,8 @@ async fn finish(cx: &Cx, body: Body) -> topcoat::Result<Response> {
591626 }
592627
593628 /// Finishes the job, then syncs the statuses of the job and of every job whose state changed
594-/// (skipped or queued by this finish). Wakes the claimers when something was queued.
629+/// (skipped or queued by this finish). Wakes the claimers when something was queued, and the
630+/// watchers of every job and run that changed.
595631 async fn finish_job(
596632 state: &AppState,
597633 job: &CiJob,
@@ -614,6 +650,7 @@ async fn finish_job(
614650 .map_err(api::internal)?;
615651
616652 let mut queued = false;
653+ let mut any_changed = false;
617654 for other in state
618655 .store
619656 .jobs_for_run(&job.run_id)
@@ -621,6 +658,14 @@ async fn finish_job(
621658 .map_err(api::internal)?
622659 {
623660 let changed = before.get(&other.id) != Some(&other.state);
661+ if changed {
662+ any_changed = true;
663+ if other.state.is_terminal() {
664+ state.ci_live.close_job(&other.id);
665+ } else {
666+ state.ci_live.bump_job(&other.id);
667+ }
668+ }
624669 if other.id == job.id
625670 || (changed && matches!(other.state, JobState::Skipped | JobState::Queued))
626671 {
@@ -628,9 +673,14 @@ async fn finish_job(
628673 crate::ci::sync_status(state, &run, &other).await;
629674 }
630675 }
676+ if any_changed {
677+ state.ci_live.bump_run(&run.id);
678+ }
631679 if queued {
632680 state.ci_waker.notify_waiters();
633681 }
682+ // A run that just went green may deploy the forge itself.
683+ self_update::maybe_deploy(state, &run).await;
634684 Ok(run.state.as_str())
635685 }
636686
crates/forge-web/src/ci/api_tests.rsmodified+130 -0
@@ -769,3 +769,133 @@ async fn job_token_reads_over_smart_http_but_never_pushes() {
769769 "a finished job's token must stop working"
770770 );
771771 }
772+
773+/// Like `bare`, with self-update enabled for `OWNER/REPO`.
774+async fn bare_with_self_update() -> Bare {
775+ let dir = tempfile::tempdir().expect("tempdir");
776+ let data_dir = dir.path().to_path_buf();
777+ let mut state = test_state(&data_dir).await;
778+ let mut config = (*state.config).clone();
779+ config.self_update.enabled = true;
780+ config.self_update.repo = format!("{OWNER}/{REPO}");
781+ state.config = Arc::new(config);
782+ let store = state.store.clone();
783+ let router = app_router(state);
784+ let owner = store
785+ .create_user(OWNER, None, "not-a-real-hash", false)
786+ .await
787+ .unwrap();
788+ let repo = store
789+ .create_repo(&owner.id, REPO, "", Visibility::Public, "main")
790+ .await
791+ .unwrap();
792+ Bare {
793+ router,
794+ store,
795+ data_dir,
796+ owner_id: owner.id,
797+ repo,
798+ _dir: dir,
799+ }
800+}
801+
802+#[tokio::test]
803+async fn runner_binary_needs_runner_auth_and_self_update() {
804+ let env = bare(|_| {}).await;
805+ let (_, secret) = runner(&env, &[]).await;
806+ let uri = "/api/v1/ci/runners/binary";
807+
808+ let sent = send(&env.router, "GET", uri, &[], Vec::new()).await;
809+ assert_eq!(sent.status, 401);
810+ // Self-update is off in this fixture, so an authorized runner gets 404.
811+ let sent = send(&env.router, "GET", uri, &[bearer(&secret)], Vec::new()).await;
812+ assert_eq!(sent.status, 404);
813+}
814+
815+#[tokio::test]
816+async fn runner_binary_streams_the_running_binary_with_its_sha() {
817+ use sha2::{Digest, Sha256};
818+
819+ let env = bare_with_self_update().await;
820+ let (_, secret) = runner(&env, &[]).await;
821+ let uri = "/api/v1/ci/runners/binary";
822+
823+ let sent = send(
824+ &env.router,
825+ "GET",
826+ uri,
827+ &[bearer("not-a-secret")],
828+ Vec::new(),
829+ )
830+ .await;
831+ assert_eq!(sent.status, 401);
832+
833+ let sent = send(&env.router, "GET", uri, &[bearer(&secret)], Vec::new()).await;
834+ assert_eq!(sent.status, 200);
835+ assert_eq!(
836+ sent.headers.get(http::header::CONTENT_TYPE).unwrap(),
837+ "application/octet-stream"
838+ );
839+ let advertised = sent
840+ .headers
841+ .get("forge-binary-sha256")
842+ .unwrap()
843+ .to_str()
844+ .unwrap();
845+ let digest = hex::encode(Sha256::digest(&sent.body));
846+ assert_eq!(digest, advertised);
847+ assert_eq!(
848+ advertised,
849+ crate::self_update::running()
850+ .expect("hashed at startup")
851+ .sha256
852+ );
853+}
854+
855+#[tokio::test]
856+async fn claim_advertises_the_binary_only_to_a_matching_target() {
857+ let env = bare_with_self_update().await;
858+ let (_, secret) = runner(&env, &["linux"]).await;
859+ let target = crate::self_update::target();
860+ let claim_with = |target: Option<String>| {
861+ let mut headers = vec![bearer(&secret)];
862+ if let Some(target) = target {
863+ headers.push(("Forge-Runner-Target", target));
864+ }
865+ headers
866+ };
867+
868+ // A 204 (no work) carries the header for a matching target.
869+ let sent = send(
870+ &env.router,
871+ "POST",
872+ "/api/v1/ci/runners/claim",
873+ &claim_with(Some(target.clone())),
874+ Vec::new(),
875+ )
876+ .await;
877+ assert_eq!(sent.status, 204);
878+ let running = crate::self_update::running().expect("hashed at startup");
879+ assert_eq!(
880+ sent.headers
881+ .get("forge-binary-sha256")
882+ .unwrap()
883+ .to_str()
884+ .unwrap(),
885+ running.sha256
886+ );
887+
888+ // No target, or another target: no header.
889+ for requested in [None, Some("riscv64-plan9".to_owned())] {
890+ let sent = send(
891+ &env.router,
892+ "POST",
893+ "/api/v1/ci/runners/claim",
894+ &claim_with(requested),
895+ Vec::new(),
896+ )
897+ .await;
898+ assert_eq!(sent.status, 204);
899+ assert!(sent.headers.get("forge-binary-sha256").is_none());
900+ }
901+}
crates/forge-web/src/ci/mod.rsmodified+2 -0
@@ -11,9 +11,11 @@ use forge_core::store::commit_status::StatusState;
1111 use crate::state::AppState;
1212
1313 pub mod api;
14+pub mod live;
1415 pub mod reaper;
1516 pub mod schedule;
1617
18+pub use live::CiLive;
1719 pub use schedule::{manual_run, on_pull_event, on_ref_update};
1820
1921 /// Longest status description written, in characters. Longer text is cut.
crates/forge-web/src/lfs/mod.rsmodified+1 -2
@@ -1,5 +1,4 @@
1-//! Git LFS over HTTP: the batch API, object GET and PUT, and verify. See
2-//! `docs/wave-b-interfaces.md`, section 2.
1+//! Git LFS over HTTP: the batch API, object GET and PUT, and verify.
32 //!
43 //! Objects are content-addressed under `<data_dir>/lfs/<oid[0..2]>/<oid[2..4]>/<oid>` and shared by
54 //! every repo. The `lfs_objects` table says which repo may use which object. Uploads stream into
crates/forge-web/src/main.rsmodified+12 -0
@@ -29,12 +29,14 @@ mod lfs_pointer;
2929 mod mail;
3030 mod mirrors;
3131 mod notify;
32+mod notify_hub;
3233 mod pages;
3334 mod post_update;
3435 mod pulls_sync;
3536 mod raw;
3637 mod render;
3738 mod replication;
39+mod self_update;
3840 mod signatures;
3941 mod ssh_backend;
4042 mod state;
@@ -137,6 +139,14 @@ async fn real_main() -> ExitCode {
137139
138140 let outcome: Result<(), BoxError> = match parts.as_slice() {
139141 [] | ["serve"] => serve(&config_path).await,
142+ ["--version"] => {
143+ println!(
144+ "forge {} {}",
145+ env!("CARGO_PKG_VERSION"),
146+ env!("FORGE_BUILD_SHA")
147+ );
148+ return ExitCode::SUCCESS;
149+ }
140150 ["hook", stage] => hook_cmd::run(stage).await.map_err(Into::into),
141151 ["admin", "create-user", username] => admin_cli::create_user(&config_path, username, admin)
142152 .await
@@ -209,6 +219,8 @@ async fn real_main() -> ExitCode {
209219 };
210220
211221 match outcome {
222+ // A self-update swap asks for a restart. systemd Restart=on-failure starts the new binary.
223+ Ok(()) if self_update::restart_pending() => ExitCode::from(self_update::RESTART_EXIT_CODE),
212224 Ok(()) => ExitCode::SUCCESS,
213225 Err(e) => {
214226 eprintln!("forge: {e}");
crates/forge-web/src/pages/archive.rsmodified+1 -1
@@ -1,5 +1,5 @@
11 //! Page: source archive at `/{owner}/{repo}/archive/{ref}.tar.gz` or `.zip`. The `git archive`
2-//! stdout is streamed as the response body (see docs/m2-interfaces.md).
2+//! stdout is streamed as the response body.
33
44 use std::process::Stdio;
55
crates/forge-web/src/pages/media.rsmodified+0 -1
@@ -2,7 +2,6 @@
22 //! from raw paths, since no ref name may start with `-`. It reads the Git LFS pointer at
33 //! `ref:path` and streams the object it names, when the repo links that object. Access follows
44 //! the raw route in `raw.rs`: public repos are open, and private repos need a signed `?token=`.
5-//! See `docs/wave-b-interfaces.md`, section 2.
65
76 use forge_core::raw_token::verify_raw;
87 use forge_core::{Repo, Visibility};
crates/forge-web/src/pages/settings_security.rsmodified+3 -0
@@ -815,6 +815,7 @@ mod tests {
815815 advisories: Default::default(),
816816 mail: Default::default(),
817817 orgs: Default::default(),
818+ self_update: Default::default(),
818819 };
819820 let state = AppState {
820821 config: Arc::new(config),
@@ -836,6 +837,8 @@ mod tests {
836837 ci_waker: Arc::new(tokio::sync::Notify::new()),
837838 mailer: Arc::new(crate::mail::FakeMailer::default()),
838839 mail_waker: Arc::new(tokio::sync::Notify::new()),
840+ notify_hub: Arc::new(crate::notify_hub::NotifyHub::new()),
841+ ci_live: Arc::new(crate::ci::CiLive::default()),
839842 };
840843 let hash = hash_password_async(PASSWORD.into()).await.unwrap();
841844 let user = store
crates/forge-web/src/raw.rsmodified+1 -1
@@ -1,5 +1,5 @@
11 //! Raw user-content origin (separate listener, explicit routes; never `.discover()`).
2-//! See docs/m2-interfaces.md, "Raw origin".
2+//! See docs/architecture.md, section 5.5.
33
44 use std::path::Path;
55 use std::process::Stdio;
crates/forge-web/src/render/highlight.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! syntect class-based highlighting (see docs/m2-interfaces.md).
1+//! syntect class-based highlighting.
22 //!
33 //! Output is class-based (`hl-` prefix) and never carries inline styles, so the
44 //! CSP without 'unsafe-inline' is respected. Each source line becomes one HTML
crates/forge-web/src/render/markdown.rsmodified+1 -1
@@ -1,4 +1,4 @@
1-//! comrak + ammonia markdown rendering (see docs/m2-interfaces.md).
1+//! comrak + ammonia markdown rendering.
22 //!
33 //! Pipeline: parse with comrak (GFM extensions, raw HTML passed through),
44 //! optionally rewrite link and image URLs in the AST, render to HTML, then
crates/forge-web/src/self_update.rsadded+575 -0
@@ -0,0 +1,575 @@
1+//! Self-update: a green CI run of the forge's own repo replaces the running binary and restarts.
2+//!
3+//! Event-driven. `maybe_deploy` runs from the runner finish path when a job finishes. Nothing
4+//! polls. After the swap the process exits with `RESTART_EXIT_CODE`, and the systemd unit
5+//! (`Restart=on-failure`) starts the new binary.
6+//!
7+//! Runners update the same way. The claim route advertises the running binary's sha256 to a runner
8+//! of the same target, and `GET /api/v1/ci/runners/binary` serves the bytes.
9+
10+use std::path::{Path, PathBuf};
11+use std::sync::OnceLock;
12+use std::sync::atomic::{AtomicBool, Ordering};
13+use std::time::Duration;
14+
15+use forge_core::ci::{CiRun, RunState};
16+use forge_core::config::SelfUpdateConfig;
17+use futures_util::StreamExt;
18+use futures_util::stream::BoxStream;
19+use http::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderValue};
20+use http_body_util::StreamBody;
21+use sha2::{Digest, Sha256};
22+use tokio::io::AsyncWriteExt;
23+use tokio_util::io::ReaderStream;
24+use topcoat::router::{Body, HeaderMap, StatusCode, response::Response};
25+
26+use crate::state::AppState;
27+
28+/// Exit code after a swap. systemd `Restart=on-failure` restarts the unit on any non-zero exit.
29+pub const RESTART_EXIT_CODE: u8 = 75;
30+/// Response header carrying the sha256 of the running binary, lowercase hex.
31+pub const SHA_HEADER: &str = "Forge-Binary-Sha256";
32+/// Request header a runner sends with its `<arch>-<os>` target.
33+pub const TARGET_HEADER: &str = "Forge-Runner-Target";
34+/// How long the staged binary may take to answer `--version`.
35+const SANITY_TIMEOUT: Duration = Duration::from_secs(10);
36+
37+/// Serialises deploys. A deploy that finds another one running is skipped.
38+static DEPLOY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
39+/// Wakes the server so it stops after a swap.
40+static RESTART: tokio::sync::Notify = tokio::sync::Notify::const_new();
41+static RESTART_REQUESTED: AtomicBool = AtomicBool::new(false);
42+
43+/// The running binary, hashed once at startup.
44+pub struct Running {
45+ /// The executable path, used to place the sibling `.prev`, `.bad` and staging files.
46+ pub path: PathBuf,
47+ pub sha256: String,
48+}
49+
50+static RUNNING: OnceLock<Option<Running>> = OnceLock::new();
51+
52+/// The running binary, hashed on first use. `None` if it cannot be read, which disables
53+/// advertising and serving, and deploys.
54+pub fn running() -> Option<&'static Running> {
55+ RUNNING
56+ .get_or_init(|| match load_running() {
57+ Ok(running) => {
58+ tracing::info!(sha256 = %running.sha256, "self-update: running binary hashed");
59+ Some(running)
60+ }
61+ Err(e) => {
62+ tracing::error!(error = %e, "self-update: cannot hash the running binary");
63+ None
64+ }
65+ })
66+ .as_ref()
67+}
68+
69+fn load_running() -> std::io::Result<Running> {
70+ let path = std::env::current_exe()?;
71+ let sha256 = sha256_reader(std::fs::File::open(image_path())?)?;
72+ Ok(Running { path, sha256 })
73+}
74+
75+/// The file that holds the running image. On Linux that is `/proc/self/exe`, which still names the
76+/// old inode after a rename over the executable. Elsewhere it is the executable path.
77+fn image_path() -> PathBuf {
78+ let proc_exe = PathBuf::from("/proc/self/exe");
79+ if proc_exe.exists() {
80+ proc_exe
81+ } else {
82+ std::env::current_exe().unwrap_or(proc_exe)
83+ }
84+}
85+
86+/// Resolves once a swap has asked the server to stop.
87+pub async fn restart_requested() {
88+ RESTART.notified().await;
89+}
90+
91+/// Whether a swap asked the process to exit with `RESTART_EXIT_CODE`.
92+pub fn restart_pending() -> bool {
93+ RESTART_REQUESTED.load(Ordering::SeqCst)
94+}
95+
96+/// `<arch>-<os>` for this build, as a runner reports it in `Forge-Runner-Target`.
97+pub fn target() -> String {
98+ format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
99+}
100+
101+/// Whether a runner's requested target is this build's target.
102+fn target_matches(requested: Option<&str>) -> bool {
103+ requested == Some(target().as_str())
104+}
105+
106+/// Whether a finished run should deploy. Pure, so the rules can be tested. A run deploys only when
107+/// it is a successful push to the configured branch of the configured repo, and it is still the
108+/// newest run on that branch. Pull request runs (including fork runs) never deploy.
109+pub fn should_deploy(
110+ cfg: &SelfUpdateConfig,
111+ repo_full_name: &str,
112+ run: &CiRun,
113+ newest_on_ref: Option<&str>,
114+) -> bool {
115+ cfg.enabled
116+ && matches!(run.state, RunState::Success)
117+ && run.trigger == "push"
118+ && run.pull_number.is_none()
119+ && repo_full_name.eq_ignore_ascii_case(&cfg.repo)
120+ && run.git_ref == format!("refs/heads/{}", cfg.branch)
121+ && newest_on_ref == Some(run.id.as_str())
122+}
123+
124+/// Why a candidate binary must not be installed, or `None` to install it. `bad_list` is the text
125+/// of `<exe>.bad`: one hex sha256 per line, `#` starts a comment.
126+pub fn skip_reason(candidate_sha: &str, running_sha: &str, bad_list: &str) -> Option<&'static str> {
127+ if candidate_sha.eq_ignore_ascii_case(running_sha) {
128+ return Some("same as the running binary");
129+ }
130+ let listed = bad_list
131+ .lines()
132+ .map(str::trim)
133+ .filter(|line| !line.is_empty() && !line.starts_with('#'))
134+ .any(|line| line.eq_ignore_ascii_case(candidate_sha));
135+ listed.then_some("listed in the bad list")
136+}
137+
138+/// `<name>.<suffix>` next to `exe`, for example `forge.prev`.
139+fn sibling(exe: &Path, suffix: &str) -> PathBuf {
140+ let name = exe
141+ .file_name()
142+ .map(|name| name.to_string_lossy().into_owned())
143+ .unwrap_or_default();
144+ exe.with_file_name(format!("{name}.{suffix}"))
145+}
146+
147+/// Called when a job finishes. Starts a deploy in the background when the run qualifies.
148+pub async fn maybe_deploy(state: &AppState, run: &CiRun) {
149+ let cfg = &state.config.self_update;
150+ if !cfg.enabled || !matches!(run.state, RunState::Success) {
151+ return;
152+ }
153+ let repo = match state.store.repo_by_id(&run.repo_id).await {
154+ Ok(Some(repo)) => repo,
155+ Ok(None) => return,
156+ Err(e) => {
157+ tracing::error!(error = %e, "self-update: loading the repo failed");
158+ return;
159+ }
160+ };
161+ let full_name = format!("{}/{}", repo.owner_name, repo.name);
162+ let newest = match state
163+ .store
164+ .latest_run_on_ref(&run.repo_id, &run.git_ref)
165+ .await
166+ {
167+ Ok(newest) => newest,
168+ Err(e) => {
169+ tracing::error!(error = %e, "self-update: loading the newest run failed");
170+ return;
171+ }
172+ };
173+ if !should_deploy(cfg, &full_name, run, newest.as_ref().map(|r| r.id.as_str())) {
174+ return;
175+ }
176+ tracing::info!(run_id = %run.id, "self-update: green push run on the deploy branch");
177+ let state = state.clone();
178+ let run = run.clone();
179+ tokio::spawn(async move { deploy(state, run).await });
180+}
181+
182+/// Installs the run's artifact over the running binary and asks the server to restart. Logs and
183+/// returns on any failure, leaving the running binary in place.
184+pub async fn deploy(state: AppState, run: CiRun) {
185+ let Ok(_guard) = DEPLOY_LOCK.try_lock() else {
186+ tracing::info!(run_id = %run.id, "self-update: a deploy is already running, skipping");
187+ return;
188+ };
189+ if let Err(e) = deploy_inner(&state, &run).await {
190+ tracing::error!(run_id = %run.id, error = %e, "self-update: deploy failed");
191+ }
192+}
193+
194+async fn deploy_inner(state: &AppState, run: &CiRun) -> Result<(), String> {
195+ let cfg = &state.config.self_update;
196+ let running = running().ok_or("the running binary was not hashed at startup")?;
197+
198+ let jobs = state
199+ .store
200+ .jobs_for_run(&run.id)
201+ .await
202+ .map_err(|e| e.to_string())?;
203+ let Some(job) = jobs.iter().find(|job| job.name == cfg.job) else {
204+ tracing::info!(run_id = %run.id, job = %cfg.job, "self-update: no such job in the run, skipping");
205+ return Ok(());
206+ };
207+ let artifact = state
208+ .store
209+ .artifact(&job.id, &cfg.artifact)
210+ .await
211+ .map_err(|e| e.to_string())?;
212+ let Some(artifact) = artifact else {
213+ tracing::info!(run_id = %run.id, artifact = %cfg.artifact, "self-update: no such artifact, skipping");
214+ return Ok(());
215+ };
216+ let read = state
217+ .blobs
218+ .read(&crate::ci::artifact_key(&artifact.id))
219+ .await
220+ .map_err(|e| e.to_string())?
221+ .ok_or("the artifact bytes are missing")?;
222+
223+ // Stage next to the executable, so the final rename stays on one filesystem.
224+ let staged = sibling(&running.path, "new");
225+ let sha = match write_staged(read.stream, &staged).await {
226+ Ok(sha) => sha,
227+ Err(e) => {
228+ let _ = std::fs::remove_file(&staged);
229+ return Err(e);
230+ }
231+ };
232+ if !sha.eq_ignore_ascii_case(&artifact.sha256) {
233+ let _ = std::fs::remove_file(&staged);
234+ return Err(format!(
235+ "artifact sha256 {sha} does not match the recorded {}",
236+ artifact.sha256
237+ ));
238+ }
239+ let bad = std::fs::read_to_string(sibling(&running.path, "bad")).unwrap_or_default();
240+ if let Some(reason) = skip_reason(&sha, &running.sha256, &bad) {
241+ let _ = std::fs::remove_file(&staged);
242+ tracing::info!(sha256 = %sha, reason, "self-update: skipped");
243+ return Ok(());
244+ }
245+ if let Err(e) = sanity_check(&staged).await {
246+ let _ = std::fs::remove_file(&staged);
247+ return Err(e);
248+ }
249+ if let Err(e) = swap(&running.path, &staged) {
250+ let _ = std::fs::remove_file(&staged);
251+ return Err(e);
252+ }
253+ tracing::info!(
254+ old_sha256 = %running.sha256,
255+ new_sha256 = %sha,
256+ exe = %running.path.display(),
257+ "self-update: binary replaced, restarting"
258+ );
259+ RESTART_REQUESTED.store(true, Ordering::SeqCst);
260+ RESTART.notify_one();
261+ Ok(())
262+}
263+
264+/// Streams the artifact to `path`, hashing as it goes. The file is executable and synced before
265+/// this returns. Returns the sha256 in lowercase hex.
266+async fn write_staged(
267+ mut stream: BoxStream<'static, std::io::Result<bytes::Bytes>>,
268+ path: &Path,
269+) -> Result<String, String> {
270+ use std::os::unix::fs::PermissionsExt;
271+
272+ let mut file = tokio::fs::File::create(path)
273+ .await
274+ .map_err(|e| format!("cannot create {}: {e}", path.display()))?;
275+ let mut hasher = Sha256::new();
276+ while let Some(chunk) = stream.next().await {
277+ let chunk = chunk.map_err(|e| e.to_string())?;
278+ hasher.update(&chunk);
279+ file.write_all(&chunk).await.map_err(|e| e.to_string())?;
280+ }
281+ file.flush().await.map_err(|e| e.to_string())?;
282+ file.sync_all().await.map_err(|e| e.to_string())?;
283+ drop(file);
284+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
285+ .map_err(|e| e.to_string())?;
286+ Ok(hex::encode(hasher.finalize()))
287+}
288+
289+/// Runs `<staged> --version` with a timeout. Only exit 0 passes.
290+async fn sanity_check(path: &Path) -> Result<(), String> {
291+ use std::process::Stdio;
292+
293+ let mut child = tokio::process::Command::new(path)
294+ .arg("--version")
295+ .stdin(Stdio::null())
296+ .stdout(Stdio::null())
297+ .stderr(Stdio::null())
298+ .kill_on_drop(true)
299+ .spawn()
300+ .map_err(|e| format!("the new binary does not run: {e}"))?;
301+ match tokio::time::timeout(SANITY_TIMEOUT, child.wait()).await {
302+ Ok(Ok(status)) if status.success() => Ok(()),
303+ Ok(Ok(status)) => Err(format!("the new binary exited with {status} for --version")),
304+ Ok(Err(e)) => Err(format!("waiting for the new binary failed: {e}")),
305+ Err(_) => Err("the new binary did not answer --version within 10 seconds".into()),
306+ }
307+}
308+
309+/// Keeps the current binary as `<exe>.prev` (a hard link when possible), then renames `staged`
310+/// over `exe`.
311+fn swap(exe: &Path, staged: &Path) -> Result<(), String> {
312+ let prev = sibling(exe, "prev");
313+ if prev.exists() {
314+ std::fs::remove_file(&prev).map_err(|e| format!("cannot remove the old prev: {e}"))?;
315+ }
316+ if std::fs::hard_link(exe, &prev).is_err() {
317+ std::fs::copy(exe, &prev).map_err(|e| format!("cannot keep the previous binary: {e}"))?;
318+ }
319+ std::fs::rename(staged, exe).map_err(|e| format!("cannot replace the binary: {e}"))
320+}
321+
322+/// Whether a claim response should advertise the binary: self-update is on and the runner asked
323+/// for this target. Adds `Forge-Binary-Sha256` to a 200 or 204 response.
324+pub fn advertise(state: &AppState, request: &HeaderMap, res: &mut Response) {
325+ if !state.config.self_update.enabled {
326+ return;
327+ }
328+ if !matches!(res.status(), StatusCode::OK | StatusCode::NO_CONTENT) {
329+ return;
330+ }
331+ let requested = request.get(TARGET_HEADER).and_then(|v| v.to_str().ok());
332+ if !target_matches(requested) {
333+ return;
334+ }
335+ let Some(running) = running() else {
336+ return;
337+ };
338+ if let Ok(value) = HeaderValue::from_str(&running.sha256) {
339+ res.headers_mut().insert(SHA_HEADER, value);
340+ }
341+}
342+
343+/// The running binary as a streamed response, with its sha256. The caller checks auth and that
344+/// self-update is enabled. `Err` when the binary cannot be read.
345+pub async fn binary_response() -> Result<Response, String> {
346+ let running = running().ok_or("the running binary was not hashed at startup")?;
347+ let file = tokio::fs::File::open(image_path())
348+ .await
349+ .map_err(|e| e.to_string())?;
350+ let len = file.metadata().await.map_err(|e| e.to_string())?.len();
351+ let frames = ReaderStream::new(file).map(|chunk| chunk.map(http_body::Frame::data));
352+ let mut res = Response::new(Body::new(StreamBody::new(frames)));
353+ let headers = res.headers_mut();
354+ headers.insert(
355+ CONTENT_TYPE,
356+ HeaderValue::from_static("application/octet-stream"),
357+ );
358+ headers.insert(CONTENT_LENGTH, HeaderValue::from(len));
359+ if let Ok(value) = HeaderValue::from_str(&running.sha256) {
360+ headers.insert(SHA_HEADER, value);
361+ }
362+ Ok(res)
363+}
364+
365+fn sha256_reader(mut reader: impl std::io::Read) -> std::io::Result<String> {
366+ let mut hasher = Sha256::new();
367+ let mut buf = vec![0u8; 64 * 1024];
368+ loop {
369+ let n = reader.read(&mut buf)?;
370+ if n == 0 {
371+ break;
372+ }
373+ hasher.update(&buf[..n]);
374+ }
375+ Ok(hex::encode(hasher.finalize()))
376+}
377+
378+#[cfg(test)]
379+mod tests {
380+ use super::*;
381+ use forge_core::ci::CiRun;
382+
383+ fn cfg() -> SelfUpdateConfig {
384+ SelfUpdateConfig {
385+ enabled: true,
386+ repo: "Tahinli/forge".into(),
387+ branch: "main".into(),
388+ job: "build".into(),
389+ artifact: "dist_forge".into(),
390+ }
391+ }
392+
393+ fn run(trigger: &str, git_ref: &str, state: RunState, pull_number: Option<i64>) -> CiRun {
394+ CiRun {
395+ id: "run-7".into(),
396+ repo_id: "repo-1".into(),
397+ number: 7,
398+ trigger: trigger.into(),
399+ git_ref: git_ref.into(),
400+ commit_oid: "a".repeat(40),
401+ pull_number,
402+ actor_id: "user-1".into(),
403+ state,
404+ config_error: None,
405+ created_at: "now".into(),
406+ finished_at: None,
407+ }
408+ }
409+
410+ /// A push run of the deploy branch that is still the newest run.
411+ fn good() -> CiRun {
412+ run("push", "refs/heads/main", RunState::Success, None)
413+ }
414+
415+ #[test]
416+ fn push_run_on_the_branch_of_the_repo_deploys() {
417+ assert!(should_deploy(
418+ &cfg(),
419+ "Tahinli/forge",
420+ &good(),
421+ Some("run-7")
422+ ));
423+ }
424+
425+ #[test]
426+ fn pull_request_runs_never_deploy() {
427+ // A PR run carries a pull number and a refs/pull ref, and its trigger is not "push".
428+ let pr = run(
429+ "pull_request",
430+ "refs/pull/3/head",
431+ RunState::Success,
432+ Some(3),
433+ );
434+ assert!(!should_deploy(&cfg(), "Tahinli/forge", &pr, Some("run-7")));
435+ // Even if a PR run were stored on the branch ref, the trigger and pull number exclude it.
436+ let pr_on_branch = run(
437+ "pull_request",
438+ "refs/heads/main",
439+ RunState::Success,
440+ Some(3),
441+ );
442+ assert!(!should_deploy(
443+ &cfg(),
444+ "Tahinli/forge",
445+ &pr_on_branch,
446+ Some("run-7")
447+ ));
448+ let push_with_pull = run("push", "refs/heads/main", RunState::Success, Some(3));
449+ assert!(!should_deploy(
450+ &cfg(),
451+ "Tahinli/forge",
452+ &push_with_pull,
453+ Some("run-7")
454+ ));
455+ }
456+
457+ #[test]
458+ fn other_branch_tag_and_manual_runs_do_not_deploy() {
459+ let feature = run("push", "refs/heads/feature", RunState::Success, None);
460+ assert!(!should_deploy(
461+ &cfg(),
462+ "Tahinli/forge",
463+ &feature,
464+ Some("run-7")
465+ ));
466+ let tag = run("tag", "refs/tags/v1", RunState::Success, None);
467+ assert!(!should_deploy(&cfg(), "Tahinli/forge", &tag, Some("run-7")));
468+ let manual = run("manual", "refs/heads/main", RunState::Success, None);
469+ assert!(!should_deploy(
470+ &cfg(),
471+ "Tahinli/forge",
472+ &manual,
473+ Some("run-7")
474+ ));
475+ }
476+
477+ #[test]
478+ fn other_repo_does_not_deploy() {
479+ assert!(!should_deploy(
480+ &cfg(),
481+ "Tahinli/other",
482+ &good(),
483+ Some("run-7")
484+ ));
485+ // Case differences in the owner or name still match the configured repo.
486+ assert!(should_deploy(
487+ &cfg(),
488+ "tahinli/FORGE",
489+ &good(),
490+ Some("run-7")
491+ ));
492+ }
493+
494+ #[test]
495+ fn only_a_successful_run_deploys() {
496+ for state in [
497+ RunState::Failure,
498+ RunState::Cancelled,
499+ RunState::Error,
500+ RunState::Running,
501+ ] {
502+ let r = run("push", "refs/heads/main", state, None);
503+ assert!(!should_deploy(&cfg(), "Tahinli/forge", &r, Some("run-7")));
504+ }
505+ }
506+
507+ #[test]
508+ fn an_older_run_on_the_branch_does_not_deploy() {
509+ assert!(!should_deploy(
510+ &cfg(),
511+ "Tahinli/forge",
512+ &good(),
513+ Some("run-9")
514+ ));
515+ assert!(!should_deploy(&cfg(), "Tahinli/forge", &good(), None));
516+ }
517+
518+ #[test]
519+ fn disabled_config_never_deploys() {
520+ let mut off = cfg();
521+ off.enabled = false;
522+ assert!(!should_deploy(
523+ &off,
524+ "Tahinli/forge",
525+ &good(),
526+ Some("run-7")
527+ ));
528+ }
529+
530+ #[test]
531+ fn same_sha_as_running_is_skipped() {
532+ let sha = "ab12";
533+ assert_eq!(
534+ skip_reason(sha, sha, ""),
535+ Some("same as the running binary")
536+ );
537+ assert_eq!(
538+ skip_reason("AB12", sha, ""),
539+ Some("same as the running binary")
540+ );
541+ assert_eq!(skip_reason("cd34", sha, ""), None);
542+ }
543+
544+ #[test]
545+ fn bad_list_entries_are_skipped() {
546+ let bad = "# rolled back 2026-10-01\n1111\n 2222 \n\n";
547+ assert_eq!(
548+ skip_reason("2222", "ffff", bad),
549+ Some("listed in the bad list")
550+ );
551+ assert_eq!(
552+ skip_reason("1111", "ffff", bad),
553+ Some("listed in the bad list")
554+ );
555+ // A comment line is not a sha.
556+ assert_eq!(skip_reason("rolled", "ffff", bad), None);
557+ assert_eq!(skip_reason("3333", "ffff", bad), None);
558+ }
559+
560+ #[test]
561+ fn target_must_match_this_build() {
562+ assert!(target_matches(Some(&target())));
563+ assert!(!target_matches(None));
564+ assert!(!target_matches(Some("riscv64-plan9")));
565+ }
566+
567+ #[test]
568+ fn sha256_of_known_bytes() {
569+ let digest = sha256_reader(&b"abc"[..]).unwrap();
570+ assert_eq!(
571+ digest,
572+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
573+ );
574+ }
575+}
crates/forge-web/src/state.rsmodified+4 -0
@@ -41,4 +41,8 @@ pub struct AppState {
4141 pub mailer: Arc<dyn crate::mail::Mailer>,
4242 /// Wakes the mail worker when a message is queued or the mail settings change.
4343 pub mail_waker: Arc<Notify>,
44+ /// Open notification streams per user. A change to an inbox publishes the new unread count.
45+ pub notify_hub: Arc<crate::notify_hub::NotifyHub>,
46+ /// Change counters of CI jobs and runs. Log followers and run pages wait on them.
47+ pub ci_live: Arc<crate::ci::CiLive>,
4448 }
crates/forge-web/src/test_support.rsmodified+3 -0
@@ -51,6 +51,7 @@ pub fn test_config(dir: &Path) -> Config {
5151 advisories: Default::default(),
5252 mail: Default::default(),
5353 orgs: Default::default(),
54+ self_update: Default::default(),
5455 }
5556 }
5657
@@ -78,6 +79,8 @@ pub async fn test_state(dir: &Path) -> AppState {
7879 ci_waker: Arc::new(Notify::new()),
7980 mailer: Arc::new(crate::mail::FakeMailer::default()),
8081 mail_waker: Arc::new(Notify::new()),
82+ notify_hub: Arc::new(crate::notify_hub::NotifyHub::new()),
83+ ci_live: Arc::new(crate::ci::CiLive::default()),
8184 }
8285 }
8386
crates/forge-web/src/webhooks/payload.rsmodified+1 -1
@@ -1,7 +1,7 @@
11 //! JSON payload builders for webhook deliveries. Each builder returns the event-specific fields.
22 //! `emit` adds `action` (when the event has one), `repository` and `sender`.
33 //!
4-//! The shapes follow docs/webhooks-interfaces.md. Links are built from `base_url` (the configured
4+//! Links are built from `base_url` (the configured
55 //! public URL), so they point at the instance a receiver can reach.
66
77 use forge_core::{Comment, Issue, PullRequest, Repo, Review, Visibility};
crates/forge-web/tests/ci_e2e.rsmodified+3 -0
@@ -420,6 +420,7 @@ impl Worker {
420420 .claim(&self.secret, &self.labels)
421421 .await
422422 .expect("claim")
423+ .job
423424 }
424425
425426 /// Claims and runs jobs until the forge has nothing left. Returns how many ran.
@@ -471,6 +472,8 @@ async fn register_runner(b: &Booted, admin_session: &str, name: &str) -> Worker
471472 images: forge_runner::config::ImagesConfig::default(),
472473 sandbox: forge_runner::config::SandboxConfig::default(),
473474 registries: Vec::new(),
475+ // The e2e runner must not replace the test binary under itself.
476+ auto_update: false,
474477 };
475478 let runner =
476479 Runner::new(config, Arc::new(HostExecutor), RunOptions::default()).expect("runner");
deploy/user/forge-rollbackadded+27 -0
@@ -0,0 +1,27 @@
1+#!/bin/sh
2+# Roll back ~/bin/forge to ~/bin/forge.prev after a unit failed.
3+# Usage: forge-rollback <unit-name>. Output goes to the journal.
4+set -eu
5+
6+unit="${1:?usage: forge-rollback <unit-name>}"
7+bin="$HOME/bin/forge"
8+prev="$bin.prev"
9+bad="$bin.bad"
10+
11+if [ ! -e "$prev" ]; then
12+ echo "forge-rollback: $unit failed and $prev does not exist, nothing to roll back"
13+ exit 0
14+fi
15+
16+sha=$(sha256sum "$bin" | cut -d' ' -f1)
17+# Keep one hash per line, even if the file was edited by hand without a final newline.
18+if [ -s "$bad" ] && [ -n "$(tail -c 1 "$bad")" ]; then
19+ echo >> "$bad"
20+fi
21+echo "$sha" >> "$bad"
22+mv -f "$prev" "$bin"
23+echo "forge-rollback: $unit failed, marked $sha bad, restored $prev"
24+
25+systemctl --user reset-failed "$unit"
26+systemctl --user start "$unit"
27+echo "forge-rollback: started $unit on the previous binary"
deploy/user/forge-rollback.serviceadded+6 -0
@@ -0,0 +1,6 @@
1+[Unit]
2+Description=roll back forge after forge.service failed
3+
4+[Service]
5+Type=oneshot
6+ExecStart=%h/bin/forge-rollback forge.service
deploy/user/forge-runner-rollback.serviceadded+6 -0
@@ -0,0 +1,6 @@
1+[Unit]
2+Description=roll back forge runner after forge-runner.service failed
3+
4+[Service]
5+Type=oneshot
6+ExecStart=%h/bin/forge-rollback forge-runner.service
deploy/user/forge-runner.serviceadded+18 -0
@@ -0,0 +1,18 @@
1+[Unit]
2+Description=forge CI runner
3+After=network-online.target
4+Wants=network-online.target
5+# Crash loops stop the unit: 3 starts within 60s, then it enters failed and OnFailure runs the rollback.
6+StartLimitIntervalSec=60
7+StartLimitBurst=3
8+OnFailure=forge-runner-rollback.service
9+
10+[Service]
11+ExecStart=%h/bin/forge runner run --config %h/.config/forge-runner/runner.toml
12+# A self-update exits with code 75, which is non-zero, so Restart=on-failure restarts it into the new binary.
13+Restart=on-failure
14+RestartSec=10
15+TimeoutStopSec=120
16+
17+[Install]
18+WantedBy=default.target
deploy/user/forge.serviceadded+20 -0
@@ -0,0 +1,20 @@
1+[Unit]
2+Description=forge
3+After=network-online.target
4+# Crash loops stop the unit: 3 starts within 60s, then it enters failed and OnFailure runs the rollback.
5+StartLimitIntervalSec=60
6+StartLimitBurst=3
7+OnFailure=forge-rollback.service
8+
9+[Service]
10+WorkingDirectory=%h
11+Environment=FORGE_CONFIG=%h/config/forge.toml
12+ExecStart=%h/bin/forge serve
13+# A self-update exits with code 75, which is non-zero, so Restart=on-failure restarts it into the new binary.
14+Restart=on-failure
15+RestartSec=2
16+NoNewPrivileges=yes
17+LimitNOFILE=65536
18+
19+[Install]
20+WantedBy=default.target