Ship musl Linux and let the CLI update itself

468f1fa3257e · AtlantisPleb · · parent e6f44974b381

Ship musl Linux and let the CLI update itself

Issue 66 asked for glibc and musl Linux builds and an `openagents update`
command. Both land here, along with the release-script gaps that finishing
them exposed.

A glibc-linked binary does not run on a musl system: the kernel reports the
missing interpreter as "no such file or directory" against a file that plainly
exists. So Linux now ships twice per architecture. The gnu artifacts keep their
unsuffixed names, because every installer already in circulation asks for those;
musl is the addition. The platform table's `file` signatures now separate the
two -- `dynamically linked` against `static` -- so a build that produced the
wrong flavour cannot be published under the other one's name.

`oa update` is the installer read from the other end, and deliberately not a
second implementation of its trust decisions. It resolves the same channel
pointer, fetches `SHA256SUMS-<version>` over its own request, refuses a missing
sums file, an absent entry, and a digest mismatch, and stages the replacement
beside the target so the rename into place is atomic. Two things it knows that
the shell script must work out: the platform is settled at compile time, and
the path to replace comes from `current_exe`, so an `oa` invoked through the
installer's symlinks replaces the file they point at.

That required closing the embedded-version gap the runbook recorded. The
release version is now threaded into the build and read through `option_env!`,
with `build.rs` declaring the rerun dependency. A binary published as
0.1.0-rc.2 that reported 0.1.0 would make every update either a no-op or a
perpetual reinstall, depending on which way the comparison fell.

Two refusals were missing from `ops/release-cli.sh`. Narrowing `--targets` made
a one-platform build look complete, so a channel could be pointed at a release
six of seven platforms could not install; coverage is now judged against the
whole platform table at the moment a channel is claimed, before anything is
uploaded. And the manifest recorded a commit without saying whether the
worktree matched it, which is a claim about what was built that a dirty build
does not support.

Verified against production: 0.1.0-rc.2 and 0.1.0-rc.3 published for all seven
platforms, both macOS artifacts notarized; the musl artifact installed and run
inside Alpine; rc.2 updated to rc.3 through the beta channel on musl; and the
Windows artifact downloaded, checksummed, and executed on Windows Server 2022,
which it had never been.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 180 · 2026-08-26T01:47:11.799976Z

Changed files

  • added crates/openagents-cli/build.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/lib.rs
  • added crates/openagents-cli/src/update.rs
  • added crates/openagents-cli/tests/update_test.rs
  • modified docs/ops/2026-08-25-cli-release-runbook.md
  • modified ops/release-cli.sh

Diff

7 files changed, +1217 -35

crates/openagents-cli/build.rs added +15

@@ -0,0 +1,15 @@

1
//! Records the version this binary was published as.
2
//!
3
//! The crate manifest names the version of the source; `ops/release-cli.sh`
4
//! names the version of the release, and for a release candidate the two
5
//! differ. `oa update` compares what the binary reports against what the
6
//! channel pointer resolves to, so the binary has to report the name it was
7
//! published under or the comparison is meaningless.
8
//!
9
//! Cargo does not otherwise track environment variables read through
10
//! `option_env!`, so a second build with a different version would reuse the
11
//! first one's artifact. This declares the dependency.
12
13
fn main() {
14
    println!("cargo:rerun-if-env-changed=OPENAGENTS_CLI_RELEASE_VERSION");
15
}
crates/openagents-cli/src/cli.rs modified +22 -1

@@ -1,7 +1,7 @@

1 1
use clap::{Args, Parser, Subcommand};
2 2
3 3
#[derive(Parser, Debug)]
4
#[command(name = "oa", version, about = "OpenAgents Rust CLI", long_about = None)]
4
#[command(name = "oa", version = crate::VERSION, about = "OpenAgents Rust CLI", long_about = None)]
5 5
pub struct Cli {
6 6
    #[command(subcommand)]
7 7
    pub command: Commands,

@@ -39,6 +39,24 @@ pub enum Commands {

39 39
    Api(ApiArgs),
40 40
    /// Trace inspection and session export
41 41
    Trace(TraceArgs),
42
    /// Replace this binary with the release the channel names
43
    #[command(alias = "self-update")]
44
    Update(UpdateArgs),
45
}
46
47
#[derive(Args, Debug)]
48
pub struct UpdateArgs {
49
    #[arg(long, help = "Release channel to resolve (default: stable)")]
50
    pub channel: Option<String>,
51
52
    #[arg(long, help = "Install this exact version instead of resolving a channel")]
53
    pub version: Option<String>,
54
55
    #[arg(long, help = "Report what the channel names without downloading anything")]
56
    pub check: bool,
57
58
    #[arg(long, help = "Reinstall even when the channel names the running version")]
59
    pub force: bool,
42 60
}
43 61
44 62
#[derive(Args, Debug)]

@@ -519,6 +537,9 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

519 537
                println!("Redacted size: {} bytes", sanitized.len());
520 538
            }
521 539
        },
540
        Commands::Update(update) => {
541
            crate::update::run(update.channel, update.version, update.check, update.force).await?;
542
        }
522 543
    }
523 544
    Ok(())
524 545
}
crates/openagents-cli/src/lib.rs modified +14

@@ -1,3 +1,16 @@

1
/// The version this binary was published as.
2
///
3
/// The crate manifest names the version of the source. A release candidate is
4
/// built from a crate at `0.1.0` and published as `0.1.0-rc.2`, and it is the
5
/// published name that `oa update` compares against the channel pointer, so
6
/// `ops/release-cli.sh` threads that name in at build time. A build that is
7
/// not a release falls back to the manifest, which is the honest answer for
8
/// one.
9
pub const VERSION: &str = match option_env!("OPENAGENTS_CLI_RELEASE_VERSION") {
10
    Some(version) => version,
11
    None => env!("CARGO_PKG_VERSION"),
12
};
13
1 14
pub mod acp;
2 15
pub mod api_passthrough;
3 16
pub mod auth;

@@ -15,3 +28,4 @@ pub mod tools;

15 28
pub mod trace;
16 29
pub mod tracker;
17 30
pub mod tui;
31
pub mod update;
crates/openagents-cli/src/update.rs added +540

@@ -0,0 +1,540 @@

1
//! `oa update` — replace this binary with the one the channel names.
2
//!
3
//! This is the installer's contract read from the other end. The script at
4
//! <https://openagents.com/install.sh> resolves a channel pointer to a version,
5
//! downloads `openagents-<version>-<platform>`, fetches
6
//! `SHA256SUMS-<version>` over a separate request, refuses when the sums file
7
//! is missing or names no entry for the artifact, refuses when the digest
8
//! disagrees, and only then makes the bytes executable. Every one of those
9
//! refusals is repeated here, because an update path that verifies less than
10
//! the install path would mean the second binary a reader receives is held to
11
//! a lower standard than the first.
12
//!
13
//! Two things this knows that the shell script has to work out at runtime. The
14
//! platform is decided at compile time: a binary knows its own architecture and
15
//! its own C library, so there is no libc probe here and no way for one to be
16
//! wrong. And the path to replace comes from the running process rather than
17
//! from a convention about where the installer puts things, so an `oa` invoked
18
//! through the symlinks the installer leaves in `~/.openagents/bin` updates the
19
//! file those symlinks point at and they keep pointing at it.
20
21
use std::path::{Path, PathBuf};
22
23
use sha2::{Digest, Sha256};
24
25
/// Where releases are published. Overridable so the flow can be exercised
26
/// against a fixture server without pointing a test at the real one.
27
pub const DEFAULT_BASE_URL: &str = "https://openagents.com/releases";
28
29
/// The channel a reader who names none is asking for.
30
pub const DEFAULT_CHANNEL: &str = "stable";
31
32
#[derive(Debug)]
33
pub enum UpdateError {
34
    /// This build has no published artifact to update to.
35
    UnsupportedPlatform { os: String, arch: String },
36
    /// The channel pointer could not be read.
37
    ChannelUnreadable { channel: String, detail: String },
38
    /// The channel resolved to something that is not a version.
39
    ChannelNotAVersion { channel: String, body: String },
40
    /// A version was named that the release naming grammar does not admit.
41
    InvalidVersion(String),
42
    /// The artifact itself could not be fetched.
43
    ArtifactUnavailable { name: String, detail: String },
44
    /// The sums file could not be fetched. Nothing is installed unverified.
45
    SumsUnavailable { version: String, detail: String },
46
    /// The sums file exists but names no entry for this artifact.
47
    SumsMissingEntry { version: String, name: String },
48
    /// The bytes that arrived are not the bytes the release published.
49
    DigestMismatch {
50
        name: String,
51
        expected: String,
52
        actual: String,
53
    },
54
    /// The binary could not be replaced.
55
    ReplaceFailed { path: PathBuf, detail: String },
56
}
57
58
impl std::fmt::Display for UpdateError {
59
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60
        match self {
61
            Self::UnsupportedPlatform { os, arch } => write!(
62
                formatter,
63
                "no release is published for {os}/{arch}, so there is nothing to update to"
64
            ),
65
            Self::ChannelUnreadable { channel, detail } => write!(
66
                formatter,
67
                "could not resolve the '{channel}' channel: {detail}"
68
            ),
69
            Self::ChannelNotAVersion { channel, body } => write!(
70
                formatter,
71
                "the '{channel}' channel returned something that is not a version: {body}"
72
            ),
73
            Self::InvalidVersion(version) => write!(
74
                formatter,
75
                "invalid version: {version} (expected X.Y.Z or X.Y.Z-suffix)"
76
            ),
77
            Self::ArtifactUnavailable { name, detail } => {
78
                write!(formatter, "could not download {name}: {detail}")
79
            }
80
            Self::SumsUnavailable { version, detail } => write!(
81
                formatter,
82
                "could not download SHA256SUMS-{version} ({detail}); \
83
                 refusing to install unverified bytes"
84
            ),
85
            Self::SumsMissingEntry { version, name } => write!(
86
                formatter,
87
                "SHA256SUMS-{version} names no entry for {name}; refusing to install"
88
            ),
89
            Self::DigestMismatch {
90
                name,
91
                expected,
92
                actual,
93
            } => write!(
94
                formatter,
95
                "checksum mismatch for {name}\n  expected {expected}\n  actual   {actual}"
96
            ),
97
            Self::ReplaceFailed { path, detail } => {
98
                write!(formatter, "could not replace {}: {detail}", path.display())
99
            }
100
        }
101
    }
102
}
103
104
impl std::error::Error for UpdateError {}
105
106
/// The platform segment of the artifact name for the build this code is
107
/// compiled into.
108
///
109
/// The installer works this out at runtime from `uname` and a search for the
110
/// glibc loader. A binary does not have to: it was built for exactly one
111
/// target, and `target_env = "musl"` is settled by the toolchain that produced
112
/// it. `None` means this build has no published counterpart, which is a
113
/// clearer thing to say than a request for a URL that will 404.
114
pub fn platform() -> Option<String> {
115
    let os = match std::env::consts::OS {
116
        "macos" => "macos",
117
        "linux" => "linux",
118
        "windows" => "windows",
119
        _ => return None,
120
    };
121
122
    let arch = match std::env::consts::ARCH {
123
        "x86_64" => "x86_64",
124
        "aarch64" => "aarch64",
125
        _ => return None,
126
    };
127
128
    // Only Linux is published in two libc flavors. The glibc artifact keeps
129
    // the unsuffixed name it has always had; musl is the one that carries a
130
    // suffix, exactly as the installer asks for it.
131
    let libc = if os == "linux" && cfg!(target_env = "musl") {
132
        "-musl"
133
    } else {
134
        ""
135
    };
136
137
    Some(format!("{os}-{arch}{libc}"))
138
}
139
140
/// The grammar `ops/release-cli.sh` and the installer both apply. A version
141
/// one of them accepts and another rejects is a release nobody can ask for.
142
pub fn valid_version(value: &str) -> bool {
143
    let (core, suffix) = match value.split_once('-') {
144
        Some((core, suffix)) => (core, Some(suffix)),
145
        None => (value, None),
146
    };
147
148
    let mut parts = core.split('.');
149
    let numeric = |part: Option<&str>| {
150
        part.is_some_and(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
151
    };
152
153
    if !numeric(parts.next()) || !numeric(parts.next()) || !numeric(parts.next()) {
154
        return false;
155
    }
156
157
    if parts.next().is_some() {
158
        return false;
159
    }
160
161
    match suffix {
162
        None => true,
163
        Some(suffix) => {
164
            !suffix.is_empty()
165
                && suffix
166
                    .bytes()
167
                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_')
168
        }
169
    }
170
}
171
172
/// Read one digest out of a `SHA256SUMS` file.
173
///
174
/// The installer's lookup is `awk '$2 == name || $2 == "*" name'`, and the
175
/// leading `*` is the binary-mode marker `sha256sum` writes. This is that
176
/// lookup, so an entry either tool accepts is an entry both accept.
177
pub fn digest_for(sums: &str, name: &str) -> Option<String> {
178
    sums.lines().find_map(|line| {
179
        let mut fields = line.split_whitespace();
180
        let digest = fields.next()?;
181
        let entry = fields.next()?;
182
183
        if entry == name || entry.strip_prefix('*') == Some(name) {
184
            Some(digest.to_string())
185
        } else {
186
            None
187
        }
188
    })
189
}
190
191
/// The published object name for a version and platform.
192
///
193
/// The artifact URL never carries a file extension, on any platform. The
194
/// `SHA256SUMS` entry for Windows *does*, because the installer appends `.exe`
195
/// to the name it searches for after downloading a URL without one. The two
196
/// disagree by design and `ops/release-cli.sh` publishes them that way, so
197
/// both spellings live here rather than being guessed at a call site.
198
pub fn artifact_name(version: &str, platform: &str) -> String {
199
    format!("openagents-{version}-{platform}")
200
}
201
202
pub fn sums_entry_name(version: &str, platform: &str) -> String {
203
    let name = artifact_name(version, platform);
204
205
    if platform.starts_with("windows-") {
206
        format!("{name}.exe")
207
    } else {
208
        name
209
    }
210
}
211
212
pub fn hex_digest(bytes: &[u8]) -> String {
213
    let digest = Sha256::digest(bytes);
214
    digest.iter().map(|byte| format!("{byte:02x}")).collect()
215
}
216
217
pub struct Updater {
218
    pub base_url: String,
219
    pub channel: String,
220
    http: reqwest::Client,
221
}
222
223
/// What an update run decided, so a caller can report it without inferring it
224
/// from printed text.
225
#[derive(Debug, PartialEq, Eq)]
226
pub enum Outcome {
227
    AlreadyCurrent {
228
        version: String,
229
    },
230
    Available {
231
        version: String,
232
    },
233
    Replaced {
234
        from: String,
235
        to: String,
236
        path: PathBuf,
237
    },
238
}
239
240
impl Updater {
241
    pub fn new(base_url: Option<String>, channel: Option<String>) -> Self {
242
        let base_url = base_url
243
            .or_else(|| std::env::var("OPENAGENTS_RELEASES_BASE_URL").ok())
244
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
245
246
        let channel = channel
247
            .or_else(|| std::env::var("OPENAGENTS_CHANNEL").ok())
248
            .unwrap_or_else(|| DEFAULT_CHANNEL.to_string());
249
250
        Self {
251
            base_url: base_url.trim_end_matches('/').to_string(),
252
            channel,
253
            http: reqwest::Client::new(),
254
        }
255
    }
256
257
    /// Resolve the channel pointer to the version it currently names.
258
    pub async fn resolve_channel(&self) -> Result<String, UpdateError> {
259
        let url = format!("{}/{}", self.base_url, self.channel);
260
261
        let response =
262
            self.http
263
                .get(&url)
264
                .send()
265
                .await
266
                .map_err(|error| UpdateError::ChannelUnreadable {
267
                    channel: self.channel.clone(),
268
                    detail: error.to_string(),
269
                })?;
270
271
        if !response.status().is_success() {
272
            return Err(UpdateError::ChannelUnreadable {
273
                channel: self.channel.clone(),
274
                detail: format!("{} answered {}", url, response.status()),
275
            });
276
        }
277
278
        let body = response
279
            .text()
280
            .await
281
            .map_err(|error| UpdateError::ChannelUnreadable {
282
                channel: self.channel.clone(),
283
                detail: error.to_string(),
284
            })?;
285
286
        let version = body.trim().to_string();
287
288
        if !valid_version(&version) {
289
            return Err(UpdateError::ChannelNotAVersion {
290
                channel: self.channel.clone(),
291
                body: version,
292
            });
293
        }
294
295
        Ok(version)
296
    }
297
298
    /// Download the artifact and prove it is the one the release published.
299
    ///
300
    /// The sums file is fetched over its own request rather than alongside the
301
    /// artifact. A digest that arrived on the same connection as the bytes it
302
    /// describes proves only that they travelled together.
303
    pub async fn fetch_verified(
304
        &self,
305
        version: &str,
306
        platform: &str,
307
    ) -> Result<Vec<u8>, UpdateError> {
308
        if !valid_version(version) {
309
            return Err(UpdateError::InvalidVersion(version.to_string()));
310
        }
311
312
        let name = artifact_name(version, platform);
313
        let url = format!("{}/{}", self.base_url, name);
314
315
        let response =
316
            self.http
317
                .get(&url)
318
                .send()
319
                .await
320
                .map_err(|error| UpdateError::ArtifactUnavailable {
321
                    name: name.clone(),
322
                    detail: error.to_string(),
323
                })?;
324
325
        if !response.status().is_success() {
326
            return Err(UpdateError::ArtifactUnavailable {
327
                name: name.clone(),
328
                detail: format!("{} answered {}", url, response.status()),
329
            });
330
        }
331
332
        let bytes = response
333
            .bytes()
334
            .await
335
            .map_err(|error| UpdateError::ArtifactUnavailable {
336
                name: name.clone(),
337
                detail: error.to_string(),
338
            })?
339
            .to_vec();
340
341
        let sums_url = format!("{}/SHA256SUMS-{}", self.base_url, version);
342
343
        let sums_response = self.http.get(&sums_url).send().await.map_err(|error| {
344
            UpdateError::SumsUnavailable {
345
                version: version.to_string(),
346
                detail: error.to_string(),
347
            }
348
        })?;
349
350
        if !sums_response.status().is_success() {
351
            return Err(UpdateError::SumsUnavailable {
352
                version: version.to_string(),
353
                detail: format!("{} answered {}", sums_url, sums_response.status()),
354
            });
355
        }
356
357
        let sums = sums_response
358
            .text()
359
            .await
360
            .map_err(|error| UpdateError::SumsUnavailable {
361
                version: version.to_string(),
362
                detail: error.to_string(),
363
            })?;
364
365
        let entry = sums_entry_name(version, platform);
366
367
        let expected = digest_for(&sums, &entry).ok_or_else(|| UpdateError::SumsMissingEntry {
368
            version: version.to_string(),
369
            name: entry.clone(),
370
        })?;
371
372
        let actual = hex_digest(&bytes);
373
374
        if !actual.eq_ignore_ascii_case(&expected) {
375
            return Err(UpdateError::DigestMismatch {
376
                name: entry,
377
                expected,
378
                actual,
379
            });
380
        }
381
382
        Ok(bytes)
383
    }
384
}
385
386
/// The file this process is running from, with symlinks resolved.
387
///
388
/// The installer links `~/.openagents/bin/oa` and `~/.openagents/bin/openagents`
389
/// at a single file under `~/.openagents/downloads`. Replacing the link target
390
/// is what keeps both names working; replacing a link would leave the other
391
/// name pointing at the old binary.
392
pub fn running_binary() -> Result<PathBuf, UpdateError> {
393
    let path = std::env::current_exe().map_err(|error| UpdateError::ReplaceFailed {
394
        path: PathBuf::from("<unknown>"),
395
        detail: format!("could not locate the running binary: {error}"),
396
    })?;
397
398
    Ok(path.canonicalize().unwrap_or(path))
399
}
400
401
/// Write `bytes` over `target` without ever leaving a partial binary there.
402
///
403
/// The new file is written beside the target so the final step is a rename
404
/// within one filesystem, which is atomic: a reader who runs `oa` during an
405
/// update gets the old binary or the new one and never half of either. On Unix
406
/// the rename also works while the old binary is executing, because the
407
/// running process holds the inode rather than the name.
408
pub fn replace_binary(target: &Path, bytes: &[u8]) -> Result<(), UpdateError> {
409
    let directory = target.parent().ok_or_else(|| UpdateError::ReplaceFailed {
410
        path: target.to_path_buf(),
411
        detail: "the running binary has no parent directory".to_string(),
412
    })?;
413
414
    let file_name = target
415
        .file_name()
416
        .map(|name| name.to_string_lossy().to_string())
417
        .unwrap_or_else(|| "oa".to_string());
418
419
    let staged = directory.join(format!(".{}.update.{}", file_name, std::process::id()));
420
421
    let fail = |detail: String| UpdateError::ReplaceFailed {
422
        path: target.to_path_buf(),
423
        detail,
424
    };
425
426
    std::fs::write(&staged, bytes).map_err(|error| fail(error.to_string()))?;
427
428
    #[cfg(unix)]
429
    {
430
        use std::os::unix::fs::PermissionsExt;
431
432
        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
433
            .map_err(|error| fail(error.to_string()))?;
434
    }
435
436
    // Windows refuses to rename over a running executable, so the old name is
437
    // moved aside first. The installer does the same thing with the same
438
    // `.old` suffix, and Windows will delete it on the next boot or the next
439
    // update, whichever a reader reaches first.
440
    #[cfg(windows)]
441
    let displaced = {
442
        let displaced = directory.join(format!("{file_name}.old"));
443
        let _ = std::fs::remove_file(&displaced);
444
445
        if target.exists() {
446
            std::fs::rename(target, &displaced).map_err(|error| fail(error.to_string()))?;
447
        }
448
449
        Some(displaced)
450
    };
451
452
    if let Err(error) = std::fs::rename(&staged, target) {
453
        let _ = std::fs::remove_file(&staged);
454
455
        #[cfg(windows)]
456
        if let Some(displaced) = displaced {
457
            let _ = std::fs::rename(&displaced, target);
458
        }
459
460
        return Err(fail(error.to_string()));
461
    }
462
463
    Ok(())
464
}
465
466
/// Run the update.
467
///
468
/// `check` stops after resolving the channel: nothing is downloaded and
469
/// nothing is written, which is what a script that wants to know whether an
470
/// update exists should call.
471
pub async fn run(
472
    channel: Option<String>,
473
    requested: Option<String>,
474
    check: bool,
475
    force: bool,
476
) -> Result<Outcome, Box<dyn std::error::Error>> {
477
    let platform = platform().ok_or_else(|| UpdateError::UnsupportedPlatform {
478
        os: std::env::consts::OS.to_string(),
479
        arch: std::env::consts::ARCH.to_string(),
480
    })?;
481
482
    let updater = Updater::new(None, channel);
483
    let current = crate::VERSION;
484
485
    let version = match requested {
486
        Some(version) => {
487
            if !valid_version(&version) {
488
                return Err(Box::new(UpdateError::InvalidVersion(version)));
489
            }
490
491
            version
492
        }
493
        None => {
494
            let resolved = updater.resolve_channel().await?;
495
496
            println!(
497
                "Channel '{}' names {} ({} is installed).",
498
                updater.channel, resolved, current
499
            );
500
501
            resolved
502
        }
503
    };
504
505
    if version == current && !force {
506
        println!("Already running {current}. Nothing to do.");
507
508
        return Ok(Outcome::AlreadyCurrent {
509
            version: version.clone(),
510
        });
511
    }
512
513
    if check {
514
        println!("Update available: {current} -> {version}");
515
516
        return Ok(Outcome::Available { version });
517
    }
518
519
    let target = running_binary()?;
520
521
    println!(
522
        "Downloading {} ({platform})...",
523
        artifact_name(&version, &platform)
524
    );
525
526
    let bytes = updater.fetch_verified(&version, &platform).await?;
527
528
    println!("  Verified sha256 {}.", hex_digest(&bytes));
529
530
    replace_binary(&target, &bytes)?;
531
532
    println!("Replaced {}.", target.display());
533
    println!("OpenAgents CLI is now {version}.");
534
535
    Ok(Outcome::Replaced {
536
        from: current.to_string(),
537
        to: version,
538
        path: target,
539
    })
540
}
crates/openagents-cli/tests/update_test.rs added +310

@@ -0,0 +1,310 @@

1
//! What `oa update` refuses.
2
//!
3
//! The install path and the update path deliver the same bytes to the same
4
//! machine, so they answer to the same rules: a version the release naming
5
//! grammar does not admit is not asked for, a sums file that names no entry
6
//! for the artifact stops the install, a digest that disagrees stops it, and
7
//! nothing lands at the target path until the bytes have been proven.
8
9
use std::collections::HashMap;
10
11
use openagents_cli::update::{
12
    artifact_name, digest_for, hex_digest, platform, replace_binary, sums_entry_name,
13
    valid_version, UpdateError, Updater,
14
};
15
16
/// A release server that serves exactly what it is given and 404s the rest.
17
///
18
/// The refusals under test are about what arrives over the wire, so they are
19
/// exercised over a real socket rather than against a mocked client that could
20
/// only prove the mock behaves.
21
async fn release_server(objects: HashMap<String, Vec<u8>>) -> String {
22
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
23
    let base = format!("http://{}", listener.local_addr().unwrap());
24
25
    tokio::spawn(async move {
26
        loop {
27
            let Ok((mut stream, _peer)) = listener.accept().await else {
28
                return;
29
            };
30
31
            let objects = objects.clone();
32
33
            tokio::spawn(async move {
34
                use tokio::io::{AsyncReadExt, AsyncWriteExt};
35
36
                let mut buffer = [0u8; 2048];
37
                let Ok(read) = stream.read(&mut buffer).await else {
38
                    return;
39
                };
40
41
                let request = String::from_utf8_lossy(&buffer[..read]);
42
                let name = request
43
                    .split_whitespace()
44
                    .nth(1)
45
                    .unwrap_or("/")
46
                    .trim_start_matches('/')
47
                    .to_string();
48
49
                let response = match objects.get(&name) {
50
                    Some(body) => {
51
                        let mut head = format!(
52
                            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
53
                            body.len()
54
                        )
55
                        .into_bytes();
56
                        head.extend_from_slice(body);
57
                        head
58
                    }
59
                    None => {
60
                        b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
61
                            .to_vec()
62
                    }
63
                };
64
65
                let _ = stream.write_all(&response).await;
66
                let _ = stream.shutdown().await;
67
            });
68
        }
69
    });
70
71
    base
72
}
73
74
fn objects(entries: Vec<(&str, &[u8])>) -> HashMap<String, Vec<u8>> {
75
    entries
76
        .into_iter()
77
        .map(|(name, body)| (name.to_string(), body.to_vec()))
78
        .collect()
79
}
80
81
#[test]
82
fn the_version_grammar_matches_the_one_the_release_publishes() {
83
    // ops/release-cli.sh: ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$
84
    assert!(valid_version("0.1.0"));
85
    assert!(valid_version("0.1.0-rc.1"));
86
    assert!(valid_version("10.20.30-alpha_2"));
87
88
    assert!(!valid_version(""));
89
    assert!(!valid_version("0.1"));
90
    assert!(!valid_version("0.1.0.1"));
91
    assert!(!valid_version("v0.1.0"));
92
    assert!(!valid_version("0.1.x"));
93
    assert!(!valid_version("0.1.0-"));
94
    assert!(!valid_version("0.1.0-rc-1"));
95
    assert!(!valid_version("0.1.0-rc 1"));
96
97
    // A channel pointer is a URL segment away from a path, and the artifact
98
    // name is built out of it.
99
    assert!(!valid_version("../../etc/passwd"));
100
    assert!(!valid_version("0.1.0/../stable"));
101
}
102
103
#[test]
104
fn the_windows_sums_entry_carries_an_extension_the_artifact_does_not() {
105
    // The installer downloads a URL with no extension and then looks up a name
106
    // with one. The published release reproduces that asymmetry, so the update
107
    // path has to as well or every Windows update fails on a missing entry.
108
    assert_eq!(
109
        artifact_name("0.1.0", "windows-x86_64"),
110
        "openagents-0.1.0-windows-x86_64"
111
    );
112
    assert_eq!(
113
        sums_entry_name("0.1.0", "windows-x86_64"),
114
        "openagents-0.1.0-windows-x86_64.exe"
115
    );
116
117
    assert_eq!(
118
        sums_entry_name("0.1.0", "linux-x86_64-musl"),
119
        "openagents-0.1.0-linux-x86_64-musl"
120
    );
121
}
122
123
#[test]
124
fn a_sums_file_is_read_the_way_the_installer_reads_one() {
125
    let sums = "\
126
aaaa  openagents-0.1.0-linux-x86_64
127
bbbb  openagents-0.1.0-linux-x86_64-musl
128
cccc *openagents-0.1.0-windows-x86_64.exe
129
";
130
131
    // The glibc and musl entries differ only by suffix, and a prefix match
132
    // would hand the musl reader the dynamically linked binary.
133
    assert_eq!(
134
        digest_for(sums, "openagents-0.1.0-linux-x86_64").as_deref(),
135
        Some("aaaa")
136
    );
137
    assert_eq!(
138
        digest_for(sums, "openagents-0.1.0-linux-x86_64-musl").as_deref(),
139
        Some("bbbb")
140
    );
141
142
    // `sha256sum` writes a `*` before the name in binary mode.
143
    assert_eq!(
144
        digest_for(sums, "openagents-0.1.0-windows-x86_64.exe").as_deref(),
145
        Some("cccc")
146
    );
147
148
    assert_eq!(digest_for(sums, "openagents-0.1.0-macos-aarch64"), None);
149
    assert_eq!(digest_for("", "openagents-0.1.0-linux-x86_64"), None);
150
}
151
152
#[test]
153
fn the_platform_is_the_one_this_binary_was_built_for() {
154
    let platform = platform().expect("this target has a published artifact");
155
156
    assert!(platform.starts_with(std::env::consts::OS));
157
    assert!(platform.contains(std::env::consts::ARCH));
158
159
    // The libc flavour is settled by the toolchain, not probed at runtime, so
160
    // it cannot disagree with the binary it names.
161
    if cfg!(all(target_os = "linux", target_env = "musl")) {
162
        assert!(platform.ends_with("-musl"));
163
    } else {
164
        assert!(!platform.ends_with("-musl"));
165
    }
166
}
167
168
#[test]
169
fn the_digest_is_the_one_shasum_would_print() {
170
    // sha256 of the empty input, which every implementation agrees on.
171
    assert_eq!(
172
        hex_digest(b""),
173
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
174
    );
175
}
176
177
#[tokio::test]
178
async fn a_channel_resolves_to_the_version_it_names() {
179
    let base = release_server(objects(vec![("stable", b"0.1.0-rc.2\n")])).await;
180
    let updater = Updater::new(Some(base), Some("stable".to_string()));
181
182
    assert_eq!(updater.resolve_channel().await.unwrap(), "0.1.0-rc.2");
183
}
184
185
#[tokio::test]
186
async fn a_channel_that_answers_with_something_else_is_refused() {
187
    // A bucket that starts serving an error page, an index listing, or a
188
    // half-written pointer must not become a version the artifact name is
189
    // built out of.
190
    let base = release_server(objects(vec![("stable", b"<html>404</html>")])).await;
191
    let updater = Updater::new(Some(base), Some("stable".to_string()));
192
193
    assert!(matches!(
194
        updater.resolve_channel().await,
195
        Err(UpdateError::ChannelNotAVersion { .. })
196
    ));
197
}
198
199
#[tokio::test]
200
async fn a_verified_artifact_is_returned() {
201
    let binary = b"the published binary";
202
    let sums = format!("{}  openagents-0.1.0-linux-x86_64\n", hex_digest(binary));
203
204
    let base = release_server(objects(vec![
205
        ("openagents-0.1.0-linux-x86_64", binary),
206
        ("SHA256SUMS-0.1.0", sums.as_bytes()),
207
    ]))
208
    .await;
209
210
    let updater = Updater::new(Some(base), None);
211
    let bytes = updater
212
        .fetch_verified("0.1.0", "linux-x86_64")
213
        .await
214
        .unwrap();
215
216
    assert_eq!(bytes, binary);
217
}
218
219
#[tokio::test]
220
async fn a_missing_sums_file_stops_the_update() {
221
    // The artifact is available and would install fine. Without the sums file
222
    // there is no way to say it is the one that was published, and an update
223
    // that installs it anyway is a weaker path than the installer's.
224
    let base = release_server(objects(vec![(
225
        "openagents-0.1.0-linux-x86_64",
226
        b"the published binary",
227
    )]))
228
    .await;
229
230
    let updater = Updater::new(Some(base), None);
231
232
    assert!(matches!(
233
        updater.fetch_verified("0.1.0", "linux-x86_64").await,
234
        Err(UpdateError::SumsUnavailable { .. })
235
    ));
236
}
237
238
#[tokio::test]
239
async fn a_sums_file_naming_no_entry_stops_the_update() {
240
    let base = release_server(objects(vec![
241
        ("openagents-0.1.0-linux-x86_64", b"the published binary"),
242
        (
243
            "SHA256SUMS-0.1.0",
244
            b"aaaa  openagents-0.1.0-macos-aarch64\n",
245
        ),
246
    ]))
247
    .await;
248
249
    let updater = Updater::new(Some(base), None);
250
251
    assert!(matches!(
252
        updater.fetch_verified("0.1.0", "linux-x86_64").await,
253
        Err(UpdateError::SumsMissingEntry { .. })
254
    ));
255
}
256
257
#[tokio::test]
258
async fn bytes_that_do_not_match_their_digest_stop_the_update() {
259
    let sums = format!(
260
        "{}  openagents-0.1.0-linux-x86_64\n",
261
        hex_digest(b"expected")
262
    );
263
264
    let base = release_server(objects(vec![
265
        ("openagents-0.1.0-linux-x86_64", b"something else entirely"),
266
        ("SHA256SUMS-0.1.0", sums.as_bytes()),
267
    ]))
268
    .await;
269
270
    let updater = Updater::new(Some(base), None);
271
272
    assert!(matches!(
273
        updater.fetch_verified("0.1.0", "linux-x86_64").await,
274
        Err(UpdateError::DigestMismatch { .. })
275
    ));
276
}
277
278
#[test]
279
fn a_replaced_binary_is_never_half_written() {
280
    let directory = std::env::temp_dir().join(format!("oa-update-{}", std::process::id()));
281
    std::fs::create_dir_all(&directory).unwrap();
282
283
    let target = directory.join("oa");
284
    std::fs::write(&target, b"old binary").unwrap();
285
286
    replace_binary(&target, b"new binary").unwrap();
287
288
    assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
289
290
    #[cfg(unix)]
291
    {
292
        use std::os::unix::fs::PermissionsExt;
293
294
        let mode = std::fs::metadata(&target).unwrap().permissions().mode();
295
        assert_eq!(mode & 0o777, 0o755, "the replacement is not executable");
296
    }
297
298
    // The staging file is gone: a directory left holding a second copy of a
299
    // 40 MB binary after every update is a leak nobody looks for.
300
    let leftovers: Vec<_> = std::fs::read_dir(&directory)
301
        .unwrap()
302
        .filter_map(Result::ok)
303
        .map(|entry| entry.file_name().to_string_lossy().to_string())
304
        .filter(|name| name != "oa")
305
        .collect();
306
307
    assert!(leftovers.is_empty(), "left behind {leftovers:?}");
308
309
    std::fs::remove_dir_all(&directory).unwrap();
310
}
docs/ops/2026-08-25-cli-release-runbook.md modified +256 -30

@@ -18,10 +18,11 @@ Under a base URL of `https://openagents.com/releases` it fetches three shapes:

18 18
| `<base>/SHA256SUMS-<version>` | One `<sha256>  <name>` line per platform |
19 19
20 20
The platform strings are `macos-aarch64`, `macos-x86_64`, `linux-x86_64`,
21
`linux-aarch64`, and `windows-x86_64`.
21
`linux-x86_64-musl`, `linux-aarch64`, `linux-aarch64-musl`, and
22
`windows-x86_64`.
22 23
23
Two details of that contract are easy to get wrong, so the script derives both
24
rather than leaving them to a human:
24
Three details of that contract are easy to get wrong, so the script derives
25
each rather than leaving it to a human:
25 26
26 27
- The artifact URL never carries a file extension, on any platform. The
27 28
  installer computes the URL before it branches on Windows and never revisits

@@ -30,6 +31,43 @@ rather than leaving them to a human:

30 31
  appends that suffix to the name it searches for. The published object and the
31 32
  checksum line therefore disagree by design. Changing either one alone breaks
32 33
  every Windows install with a checksum mismatch.
34
- The glibc Linux artifacts carry no libc suffix and the musl ones do. Renaming
35
  the glibc artifacts to `linux-x86_64-gnu` would be tidier and would strand
36
  every installer already in circulation, which asks for the unsuffixed name.
37
38
## The two Linux builds
39
40
A glibc-linked executable does not run on a musl system. The kernel cannot find
41
the interpreter named in the binary's `PT_INTERP` and reports it as `no such
42
file or directory` against a path that plainly exists, which is close to the
43
least legible failure a first install can produce.
44
45
So Linux ships twice per architecture. The `-gnu` targets are dynamically
46
linked and name `/lib64/ld-linux-x86-64.so.2` or `/lib/ld-linux-aarch64.so.1`.
47
The `-musl` targets are statically linked and name no interpreter at all.
48
49
The installer chooses between them by asking whether the glibc loader for the
50
reader's architecture exists, which is the question that actually decides it
51
rather than a proxy for it. Distribution detection would need files a minimal
52
image may not carry, and `ldd` disagrees with itself across implementations:
53
GNU's prints a version banner to stdout and exits 0, musl's prints `musl libc`
54
to stderr, and BusyBox's does neither. Loader presence needs no tools at all.
55
A system that has the loader can run either artifact and takes the dynamically
56
linked one; a system that does not can only run the static one. That reads
57
correctly on Alpine, on a BusyBox or distroless image with no `ldd`, on a
58
Debian host with the `musl` package installed beside glibc, and on NixOS, where
59
the loader lives in the Nix store and the static build is genuinely the right
60
answer. Every way the test can be wrong sends the reader to the artifact that
61
still runs.
62
63
`test/openagents_web/install_script_test.exs` in the `openagents.com`
64
repository extracts `linux_libc` from the served script and runs it against
65
fixture roots covering each of those cases.
66
67
The `file` signatures in the platform table encode the difference — the gnu
68
rows require `dynamically linked` and the musl rows require `static` — so a
69
build that silently produced the wrong flavour cannot be published under the
70
other one's name.
33 71
34 72
## Prerequisites
35 73

@@ -92,6 +130,14 @@ channel pointing at a version that only covers three platforms looks to

92 130
everyone else like an outage. Pass `--allow-partial` when a partial release is
93 131
what you actually want.
94 132
133
**A channel pointed at a release that does not cover every platform.** The
134
partial-release check above only sees platforms that were attempted, so
135
narrowing `--targets` makes a one-platform build look complete. That is fine
136
for a rehearsal and fatal for a channel, because a channel is what readers
137
resolve without naming a version. Coverage is therefore judged against the
138
whole platform table rather than against the request, and only at the moment a
139
channel is about to be claimed — before anything is uploaded.
140
95 141
**A prerelease claiming a channel.** A version with a suffix, such as
96 142
`0.1.0-rc.1`, cannot become the target of a channel without
97 143
`--allow-prerelease-channel`. Rehearsals publish release candidates; the

@@ -102,23 +148,41 @@ secret file, or a notarization result other than `Accepted` stops the run. Pass

102 148
`--skip-notarization` to build macOS artifacts without submitting them, which
103 149
is useful while iterating and is recorded as `skipped` in the manifest.
104 150
105
## Why macOS artifacts ship bare rather than zipped
106
107
Apple cannot staple a notarization ticket to a bare Mach-O executable. Only a
108
container such as a `.zip`, `.dmg`, or `.pkg` carries a stapled ticket, and
109
`xcrun stapler staple` on a bare binary fails with error 73.
110
111
The artifact still ships bare, for two reasons. The installer downloads a
112
single file and marks it executable, so shipping a container would mean
113
changing a landed contract. And the thing stapling buys — offline Gatekeeper
114
verification — is not consulted on this install path at all, because `curl`
115
sets no `com.apple.quarantine` attribute on what it writes.
116
117
The artifacts are signed and notarized anyway, so that the paths where
118
quarantine *does* apply still succeed. `spctl --assess -t install` reports
119
`source=Notarized Developer ID` against the published binary, which is Apple's
120
online lookup of the ticket recorded for that code directory hash. The script
121
prints that assessment during every macOS build.
151
## Why the artifacts are bare binaries rather than tarballs
152
153
Issue 66 asked for `openagents-<os>-<arch>.tar.gz`. The release publishes bare
154
executables instead. This is the deliberate shape, not an unfinished step.
155
156
A tarball would buy three things. It compresses, which matters to whoever pays
157
for egress; it carries a directory of files, which matters when a release is
158
more than one file; and on macOS it is a container, which is the only thing
159
Apple can staple a notarization ticket to.
160
161
None of the three pays here. The binaries are already stripped and the wire is
162
already compressed by the transport where it helps. A release is exactly one
163
file per platform, so an archive would exist only to be immediately unpacked.
164
And the stapling argument runs backwards once you follow it: Apple cannot
165
staple a ticket to a bare Mach-O — `xcrun stapler staple` fails with error 73 —
166
but the thing stapling buys is offline Gatekeeper verification, and Gatekeeper
167
is not consulted on this install path at all, because `curl` sets no
168
`com.apple.quarantine` attribute on what it writes. The artifacts are signed
169
and notarized regardless, so the paths where quarantine *does* apply still
170
succeed; `spctl --assess -t install` reports `source=Notarized Developer ID`
171
from Apple's online lookup of the ticket recorded against the code directory
172
hash. The script prints that assessment during every macOS build.
173
174
What a tarball would cost is concrete. Unpacking is a second failure mode
175
between a verified download and an executable on disk, and `tar` is one more
176
tool the installer would have to find and one more thing to refuse when it is
177
missing. The checksum would cover the archive rather than the executable, so a
178
reader verifying a binary they already have could no longer compare it against
179
the published sums. And the change is not additive: the installer, the sums
180
file, `oa update`, and every published object name would all have to move
181
together, breaking every installer already in circulation.
182
183
If a release ever becomes more than one file per platform — a shell completion
184
set, a man page, a sidecar — the calculation changes and the tarball becomes
185
the right container. It is not that today.
122 186
123 187
## Credential handling
124 188

@@ -137,23 +201,185 @@ message, or an issue comment. Reference them by path and variable name.

137 201
Every run writes `dist/releases/<version>/release-manifest.json` recording, per
138 202
platform, the Rust target triple, the builder, the SHA-256, the byte count, the
139 203
notarization status, and the notarization submission id. It also records the
140
Git commit the artifacts were built from. Keep it with the release record; it
141
is the evidence for what shipped.
204
Git commit the artifacts were built from and, in `git_clean`, whether the
205
worktree matched that commit. A dirty build produces artifacts no commit
206
describes, and someone checking out that sha later would build something else
207
with no way to know. Rehearsals are routinely built dirty; a release should not
208
be.
209
210
`dist/` is ignored by Git, so keep the manifest with the release record — the
211
issue or changelog entry the release belongs to. It is the evidence for what
212
shipped.
142 213
143 214
## Verifying a published release by hand
144 215
145 216
```sh
146
curl -fsSL https://openagents.com/releases/SHA256SUMS-0.1.0-rc.1
147
curl -fsSL -o oa https://openagents.com/releases/openagents-0.1.0-rc.1-macos-aarch64
217
curl -fsSL https://openagents.com/releases/SHA256SUMS-0.1.0-rc.3
218
curl -fsSL -o oa https://openagents.com/releases/openagents-0.1.0-rc.3-macos-aarch64
148 219
shasum -a 256 oa
149 220
codesign -dv --verbose=4 oa
150 221
spctl --assess -vv -t install oa
151 222
```
152 223
153
## Known gap: the embedded version
224
The Linux pair is worth checking against each other, because the whole point of
225
shipping two is that they are not the same file:
226
227
```sh
228
curl -fsSL -o oa-gnu  https://openagents.com/releases/openagents-0.1.0-rc.3-linux-x86_64
229
curl -fsSL -o oa-musl https://openagents.com/releases/openagents-0.1.0-rc.3-linux-x86_64-musl
230
file oa-gnu oa-musl
231
```
232
233
`oa-gnu` must read `dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2`
234
and `oa-musl` must read `statically linked`. Those two strings are what the
235
installer's choice is about.
236
237
The musl build only proves itself on a musl system:
238
239
```sh
240
docker run --rm --platform linux/amd64 -v "$PWD/install.sh:/install.sh:ro" \
241
  alpine:3.20 sh -c 'apk add -q --no-cache curl && sh /install.sh 0.1.0-rc.3 && oa --version'
242
```
243
244
Alpine ships no bash, which is why the installer is POSIX shell. If that
245
command ever needs `apk add bash` to work, a bashism has crept back in.
246
247
## The version the binary reports
248
249
`--version` names the release, and the script passes it to the build as
250
`OPENAGENTS_CLI_RELEASE_VERSION`. `openagents_cli::VERSION` reads it through
251
`option_env!` and falls back to the crate manifest for any build that is not a
252
release, which is the honest answer for one. `crates/openagents-cli/build.rs`
253
declares `cargo:rerun-if-env-changed` on that variable, so a second build under
254
a different version rebuilds rather than reusing the first one's artifact.
255
256
This matters beyond cosmetics. `oa update` compares what the running binary
257
reports against what the channel pointer resolves to. A binary published as
258
`0.1.0-rc.2` that reported `0.1.0` would make every update either a no-op or a
259
perpetual reinstall, depending on which way the comparison fell.
260
261
## Where `oa update` fits
262
263
`oa update` is the installer read from the other end, and it is deliberately
264
not a second implementation of the trust decisions. It resolves the same
265
channel pointer, downloads the same artifact, fetches `SHA256SUMS-<version>`
266
over its own request, refuses a missing sums file, an absent entry, and a
267
digest mismatch, and only then writes anything.
268
269
Two things it knows that the shell script has to work out. The platform is
270
settled at compile time — a binary knows its own architecture and its own C
271
library, so there is no libc probe in the update path and no way for one to be
272
wrong. And the path it replaces comes from `current_exe`, canonicalized, so an
273
`oa` invoked through the symlinks in `~/.openagents/bin` replaces the file
274
those symlinks point at and both names keep working.
275
276
The replacement is staged beside the target and renamed into place, so a reader
277
who runs `oa` during an update gets the old binary or the new one and never
278
half of either.
279
280
## There is no CDN in front of the releases, and that is a decision
281
282
Issue 66 asked for CDN caching under `https://openagents.com/releases/...`.
283
There is none. What exists is a Google global external Application Load
284
Balancer (`sarah-urlmap` → `sarah-backend`, three zonal instance groups in
285
`us-central1`) with `enableCDN: false`, fronting the Phoenix app, which proxies
286
`storage.googleapis.com`. Requests already enter Google's edge near the reader
287
and cross Google's backbone from there; what they do not do is get cached at
288
that edge.
289
290
The measurement that settles whether it matters, taken from a laptop on a
291
domestic connection against the published 6.5 MB `linux-x86_64` artifact:
292
293
| Path | Time | Throughput |
294
| --- | --- | --- |
295
| `openagents.com/releases/...` (Phoenix proxy) | 0.66 s | 9.9 MB/s |
296
| `storage.googleapis.com/...` (direct) | 0.76 s | 8.6 MB/s |
297
298
The proxy is not slower than the bucket it proxies. Both runs are saturating
299
the client's link, not the origin, and the controller streams chunk by chunk so
300
a 40 MB artifact is never a 40 MB message in the app. The bottleneck a CDN
301
removes is not the bottleneck that exists.
302
303
What a CDN would buy, when the numbers change: repeat downloads served from the
304
edge instead of `us-central1`, which matters for a fleet installing the same
305
version, and for readers far from Iowa; and insulation for the app from a
306
release-day spike, which matters when the app and the download path stop being
307
the same origin's problem.
308
309
What it would take, concretely. Create a backend bucket over
310
`openagentsgemini-cli-releases` with `--enable-cdn`; add a path matcher to
311
`sarah-urlmap` sending `/releases/*` to it with a `pathPrefixRewrite` to `/`,
312
because the bucket is flat and the objects carry no `releases/` prefix; and set
313
`Cache-Control` on the objects at upload time, since a backend bucket serves
314
object metadata rather than headers a controller chose. `ops/release-cli.sh`
315
already sets `public, max-age=60` on channel pointers, which is the half that
316
is easy to get catastrophically wrong — a channel cached for a year pins every
317
installer that reads it to the release it named that day — but artifacts and
318
sums files are uploaded without one and would need
319
`public, max-age=31536000, immutable` to match what `ReleaseController` sends
320
today.
321
322
Two things to weigh before doing it. `sarah-urlmap` currently has no path
323
matchers at all and routes the entire site to one default service, so this is a
324
routing change to production for openagents.com, not an isolated addition.
325
And it moves `/releases` off `ReleaseController`, which is where the object
326
name allowlist, the `Range` passthrough, and the cache-lifetime split live;
327
the bucket is world-readable and flat so the allowlist guards nothing that is
328
not already public, but the behaviour would have to be re-established in the
329
URL map rather than assumed.
330
331
The recommendation is to leave it until there is traffic that justifies a
332
production routing change. Do not describe openagents.com as CDN-backed for
333
releases in the meantime.
334
335
## Verifying the Windows artifact
336
337
The release machine is an Apple Silicon Mac. It cross-compiles the Windows
338
build and until now could not run it, so the artifact shipped on the strength
339
of its PE header alone.
340
341
Wine is not the way out of that here. An `x86_64` container under Docker's
342
emulation inherits the host's 16 KiB page size, and Wine assumes 4 KiB:
343
`wineboot` aborts on `anon_mmap_fixed: Assertion !((UINT_PTR)start &
344
host_page_mask) failed` before a prefix is ever created. That is structural,
345
not a missing package.
346
347
A throwaway GCE Windows instance runs the real artifact on a real Windows
348
kernel, with no RDP and no interactive step, because a startup script's output
349
lands on the serial console:
350
351
```sh
352
export CLOUDSDK_CONFIG=/Users/christopherdavid/work/.secrets/gcloud-sa-config
353
354
gcloud compute instances create oa-cli-windows-verify \
355
  --project openagentsgemini --zone us-central1-a \
356
  --machine-type e2-standard-2 \
357
  --image-family windows-2022-core --image-project windows-cloud \
358
  --metadata-from-file windows-startup-script-ps1=verify.ps1 \
359
  --no-service-account --no-scopes
360
361
gcloud compute instances get-serial-port-output oa-cli-windows-verify \
362
  --project openagentsgemini --zone us-central1-a | grep OA-PROOF
363
```
364
365
`verify.ps1` downloads the published artifact from `openagents.com`, compares
366
`Get-FileHash` against the entry in `SHA256SUMS-<version>`, and runs
367
`--version`, `--help`, and `computer probe`, printing each result with a
368
greppable prefix. The instance needs no service account: it reads nothing from
369
Google and fetches the artifact over its external IP like any other reader.
370
371
Set `$ProgressPreference = 'SilentlyContinue'` before `Invoke-WebRequest`. The
372
progress bar rendering to a serial console makes a 9 MB download look like a
373
hang.
374
375
Re-run against a later version by replacing the metadata and resetting the
376
instance — a Windows startup script runs on every boot:
377
378
```sh
379
gcloud compute instances add-metadata oa-cli-windows-verify ... \
380
  --metadata-from-file windows-startup-script-ps1=verify.ps1
381
gcloud compute instances reset oa-cli-windows-verify ...
382
```
154 383
155
The `--version` value names the release, but the version the binary reports
156
comes from the `openagents-cli` crate manifest. A release candidate built from
157
a crate at `0.1.0` installs correctly and then reports `0.1.0` rather than
158
`0.1.0-rc.1`. The two agree for a real release and diverge for a rehearsal.
159
Closing this means threading the release version into the build.
384
Delete the instance when the release is verified. It exists to answer one
385
question and costs money while it sits there.
ops/release-cli.sh modified +60 -4

@@ -20,7 +20,7 @@

20 20
#
21 21
# Options:
22 22
#   --version X.Y.Z[-suffix]  Required. The version to build and name.
23
#   --targets "a b c"         Platforms to attempt. Defaults to all five.
23
#   --targets "a b c"         Platforms to attempt. Defaults to all seven.
24 24
#   --publish                 Upload to the release bucket. Off by default.
25 25
#   --channel NAME            Point a channel at this version after publishing.
26 26
#   --allow-partial           Publish even though some platforms are missing.

@@ -40,10 +40,19 @@ repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)

40 40
# requested triple -- and a release that ships a darwin binary under a
41 41
# linux-x86_64 name is worse than one that admits it built four of five. Every
42 42
# artifact is read back and matched against this signature before it is staged.
43
#
44
# The two Linux libc flavors are separate platforms because they are separate
45
# artifacts: the gnu build is dynamically linked and names a glibc loader that
46
# a musl system does not have, and the musl build is statically linked and
47
# names no interpreter at all. Their signatures say so -- `dynamically linked`
48
# against `static-pie linked` -- which is what keeps the pair from being
49
# published under each other's names.
43 50
platform_table='macos-aarch64|aarch64-apple-darwin|cargo|Mach-O 64-bit executable arm64
44 51
macos-x86_64|x86_64-apple-darwin|cargo|Mach-O 64-bit executable x86_64
45
linux-x86_64|x86_64-unknown-linux-gnu|zigbuild|ELF 64-bit LSB*x86-64
46
linux-aarch64|aarch64-unknown-linux-gnu|zigbuild|ELF 64-bit LSB*ARM aarch64
52
linux-x86_64|x86_64-unknown-linux-gnu|zigbuild|ELF 64-bit LSB*x86-64*dynamically linked
53
linux-x86_64-musl|x86_64-unknown-linux-musl|zigbuild|ELF 64-bit LSB*x86-64*static
54
linux-aarch64|aarch64-unknown-linux-gnu|zigbuild|ELF 64-bit LSB*ARM aarch64*dynamically linked
55
linux-aarch64-musl|aarch64-unknown-linux-musl|zigbuild|ELF 64-bit LSB*ARM aarch64*static
47 56
windows-x86_64|x86_64-pc-windows-gnu|zigbuild|PE32+ executable*x86-64'
48 57
49 58
all_platforms=$(printf '%s\n' "$platform_table" | cut -d'|' -f1 | tr '\n' ' ')

@@ -238,7 +247,14 @@ for platform in $targets; do

238 247
    build_command='cargo build'
239 248
  fi
240 249
241
  if ! (cd "$repo_root" && $build_command --release -p openagents-cli --target "$triple") \
250
  # The release version is threaded into the build rather than left to the
251
  # crate manifest. `oa --version` is what `oa update` compares against the
252
  # channel pointer, so a binary published as 0.1.0-rc.2 that reports 0.1.0
253
  # would make every update either a no-op or a reinstall depending on which
254
  # way the comparison fell. `build.rs` declares the dependency on this
255
  # variable, so changing it rebuilds.
256
  if ! (cd "$repo_root" && OPENAGENTS_CLI_RELEASE_VERSION="$version" \
257
    $build_command --release -p openagents-cli --target "$triple") \
242 258
    >"$build_log" 2>&1; then
243 259
    echo "  SKIP: build failed (see $build_log)"
244 260
    tail -5 "$build_log" | sed 's/^/    /'

@@ -307,12 +323,24 @@ for platform in $built; do

307 323
  printf '%s  %s\n' "$sha" "$sums_name" >>"$sums"
308 324
done
309 325
326
# The commit alone is not a claim about what was built. A dirty worktree
327
# produces artifacts that no commit describes, and a manifest naming a commit
328
# it does not match is worse than one that admits the gap: someone checking out
329
# that sha later would build something else and have no way to know. Rehearsals
330
# are routinely built dirty; releases should not be.
331
if [ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null)" ]; then
332
  git_clean=false
333
else
334
  git_clean=true
335
fi
336
310 337
cat >"$dist/release-manifest.json" <<EOF
311 338
{
312 339
  "schema": "openagents.cli-release.v1",
313 340
  "version": "$version",
314 341
  "built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
315 342
  "git_sha": "$(git -C "$repo_root" rev-parse --verify HEAD)",
343
  "git_clean": $git_clean,
316 344
  "host": "$(uname -sm)",
317 345
  "artifacts": [$(printf '%s' "$manifest_entries" | sed '$ s/,$//')
318 346
  ]

@@ -336,6 +364,34 @@ if [ -n "$missing" ] && [ "$allow_partial" = 0 ]; then

336 364
  exit 1
337 365
fi
338 366
367
# The check above only sees platforms that were attempted. Narrowing --targets
368
# makes a one-platform build look complete, and a release that "built
369
# everything it was asked for" can still be a release that six of seven
370
# platforms cannot install. That is fine for a rehearsal and fatal for a
371
# channel, because a channel is the thing readers resolve without naming a
372
# version. So coverage is judged against the whole platform table, not against
373
# the request, at the moment a channel is about to be claimed.
374
if [ -n "$channel" ] && [ "$allow_partial" = 0 ]; then
375
  uncovered=''
376
  for platform in $all_platforms; do
377
    case " $built " in
378
      *" $platform "*) ;;
379
      *) uncovered="$uncovered $platform" ;;
380
    esac
381
  done
382
383
  if [ -n "$uncovered" ]; then
384
    echo "Refusing to point '$channel' at $version." >&2
385
    echo "" >&2
386
    echo "This release does not cover:$uncovered" >&2
387
    echo "" >&2
388
    echo "Readers who resolve '$channel' on those platforms would get a bare" >&2
389
    echo "download failure. Build every platform, or pass --allow-partial if a" >&2
390
    echo "channel that only some platforms can follow is what you mean." >&2
391
    exit 1
392
  fi
393
fi
394
339 395
if [ "$publish" = 0 ]; then
340 396
  echo "Not publishing (--publish was not passed)."
341 397
  exit 0

This page updates live while a promote is in flight · changelog