1#![allow(clippy::doc_lazy_continuation)]
2use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{Context, Result, bail};
12use chrono::{DateTime, Utc};
13use nixfleet_proto::{FleetResolved, RevocationEntry, Revocations, RolloutId};
14use nixfleet_reconciler::project_manifest;
15use sha2::{Digest, Sha256};
16
17mod git;
18mod sign;
19
20pub use git::render_commit_message;
21
22use git::{git_commit_release, git_head_sha, git_push_release};
23use sign::{sign, smoke_verify, write_release};
24
25#[derive(Debug, Clone)]
27pub enum HostsSpec {
28 Auto,
30 AutoExclude(Vec<String>),
32 Explicit(Vec<String>),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum HostKind {
41 Nixos,
42 Darwin,
43}
44
45impl HostKind {
46 pub fn attr_prefix(self) -> &'static str {
47 match self {
48 HostKind::Nixos => "nixosConfigurations",
49 HostKind::Darwin => "darwinConfigurations",
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct ReleaseConfig {
57 pub flake_dir: PathBuf,
58 pub fleet_resolved_attr: String,
60 pub hosts: HostsSpec,
61 pub push_cmd: Option<String>,
63 pub sign_cmd: String,
66 pub signature_algorithm: String,
68 pub release_dir: PathBuf,
69 pub artifact_name: String,
70 pub git_commit: bool,
71 pub git_push: Option<GitPushTarget>,
72 pub commit_template: String,
73 pub git_user_name: Option<String>,
74 pub git_user_email: Option<String>,
75 pub smoke_verify: bool,
78 pub reuse_unchanged_signature: bool,
81 pub revocations_attr: Option<String>,
85 pub bootstrap_nonces_attr: Option<String>,
90 pub pin_source_url: Option<String>,
95}
96
97#[derive(Debug, Clone)]
98pub struct GitPushTarget {
99 pub remote: String,
100 pub branch: String,
101}
102
103#[derive(Debug)]
104pub enum RunOutcome {
105 Released {
107 commit_sha: Option<String>,
108 hosts: Vec<String>,
109 },
110 NoChange,
112}
113
114pub fn run(config: &ReleaseConfig) -> Result<RunOutcome> {
115 validate_config(config)?;
116
117 tracing::info!(
118 target: "nixfleet_release",
119 flake = %config.flake_dir.display(),
120 "release pipeline start",
121 );
122
123 let hosts = enumerate_hosts(config)?;
124 if hosts.is_empty() {
125 bail!("no hosts to release - empty enumeration");
126 }
127 let host_names: Vec<&str> = hosts.iter().map(|(n, _)| n.as_str()).collect();
128 tracing::info!(count = hosts.len(), hosts = ?host_names, "enumerated");
129
130 let mut resolved = eval_fleet_resolved(config)?;
133 let current_commit = git_head_sha(&config.flake_dir).ok();
134 let now = Utc::now();
135 filter_expired_pins(&mut resolved, now);
136 validate_pin_source_url(config, &resolved, current_commit.as_deref())?;
137
138 let built = build_hosts(config, &hosts, &resolved, current_commit.as_deref())?;
139 tracing::info!(built = built.len(), total = hosts.len(), "build done");
140
141 if let Some(cmd) = &config.push_cmd {
142 for (host, path) in built.iter() {
143 let hash = closure_hash(path);
144 push_one(cmd, host, path, &hash)?;
145 }
146 }
147
148 let hashes: BTreeMap<String, String> = built
149 .iter()
150 .map(|(h, p)| (h.clone(), closure_hash(p)))
151 .collect();
152 inject_closure_hashes(&mut resolved, &hashes);
153
154 let release_path = config.release_dir.join(&config.artifact_name);
155 let signature_path = config
156 .release_dir
157 .join(format!("{}.sig", config.artifact_name));
158 let preserved_signed_at: Option<DateTime<Utc>> = if config.reuse_unchanged_signature {
159 load_existing_signed_at_if_unchanged(&release_path, &resolved)?
160 } else {
161 None
162 };
163
164 let signed_at = preserved_signed_at.unwrap_or_else(Utc::now);
165 let ci_commit = current_commit.clone();
166 stamp_meta(
167 &mut resolved,
168 signed_at,
169 ci_commit.clone(),
170 &config.signature_algorithm,
171 );
172
173 let canonical = canonicalize_resolved(&resolved)?;
174
175 let sig_bytes = if preserved_signed_at.is_some() && signature_path.exists() {
176 std::fs::read(&signature_path).context("read existing signature")?
177 } else {
178 sign(&config.sign_cmd, canonical.as_bytes())?
179 };
180
181 if config.smoke_verify {
182 smoke_verify(canonical.as_bytes(), &sig_bytes)?;
183 }
184
185 write_release(
186 &config.release_dir,
187 &config.artifact_name,
188 canonical.as_bytes(),
189 &sig_bytes,
190 )?;
191
192 let mut revocations_paths: Vec<PathBuf> = Vec::new();
196 if let Some(attr) = &config.revocations_attr {
197 let entries = eval_revocations(config, attr)?;
198 let revs = Revocations {
199 schema_version: 1,
200 revocations: entries,
201 meta: nixfleet_proto::Meta {
202 schema_version: 1,
203 signed_at: Some(signed_at),
204 ci_commit: ci_commit.clone(),
205 signature_algorithm: Some(config.signature_algorithm.clone()),
206 },
207 };
208 let revs_json = serde_json::to_string(&revs).context("serialise revocations.json")?;
209 let revs_canonical = nixfleet_canonicalize::canonicalize(&revs_json)
210 .context("canonicalize revocations.json")?;
211 let revs_sig_path = config.release_dir.join("revocations.json.sig");
212 let revs_path = config.release_dir.join("revocations.json");
213 let revs_sig_bytes = if revs_path.exists()
215 && revs_sig_path.exists()
216 && std::fs::read(&revs_path).ok().as_deref() == Some(revs_canonical.as_bytes())
217 {
218 std::fs::read(&revs_sig_path).context("read existing revocations signature")?
219 } else {
220 sign(&config.sign_cmd, revs_canonical.as_bytes())?
221 };
222 write_release(
223 &config.release_dir,
224 "revocations.json",
225 revs_canonical.as_bytes(),
226 &revs_sig_bytes,
227 )?;
228 revocations_paths.push(revs_path);
229 revocations_paths.push(revs_sig_path);
230 tracing::info!(
231 target: "nixfleet_release",
232 entries = revs.revocations.len(),
233 "revocations.json signed + written",
234 );
235 }
236
237 let mut bootstrap_nonces_paths: Vec<PathBuf> = Vec::new();
241 if let Some(attr) = &config.bootstrap_nonces_attr {
242 let raw_entries = eval_bootstrap_nonces(config, attr)?;
243 let pruned = prune_expired_bootstrap_nonces(raw_entries, signed_at);
244 let bn = nixfleet_proto::BootstrapNonces {
245 schema_version: 1,
246 bootstrap_nonces: pruned,
247 meta: nixfleet_proto::Meta {
248 schema_version: 1,
249 signed_at: Some(signed_at),
250 ci_commit: ci_commit.clone(),
251 signature_algorithm: Some(config.signature_algorithm.clone()),
252 },
253 };
254 let bn_json = serde_json::to_string(&bn).context("serialise bootstrap-nonces.json")?;
255 let bn_canonical = nixfleet_canonicalize::canonicalize(&bn_json)
256 .context("canonicalize bootstrap-nonces.json")?;
257 let bn_path = config.release_dir.join("bootstrap-nonces.json");
258 let bn_sig_path = config.release_dir.join("bootstrap-nonces.json.sig");
259 let bn_sig_bytes = if bn_path.exists()
260 && bn_sig_path.exists()
261 && std::fs::read(&bn_path).ok().as_deref() == Some(bn_canonical.as_bytes())
262 {
263 std::fs::read(&bn_sig_path).context("read existing bootstrap-nonces signature")?
264 } else {
265 sign(&config.sign_cmd, bn_canonical.as_bytes())?
266 };
267 write_release(
268 &config.release_dir,
269 "bootstrap-nonces.json",
270 bn_canonical.as_bytes(),
271 &bn_sig_bytes,
272 )?;
273 bootstrap_nonces_paths.push(bn_path);
274 bootstrap_nonces_paths.push(bn_sig_path);
275 tracing::info!(
276 target: "nixfleet_release",
277 entries = bn.bootstrap_nonces.len(),
278 "bootstrap-nonces.json signed + written",
279 );
280 }
281
282 let mut manifest_paths: Vec<PathBuf> = Vec::new();
285 let fleet_resolved_hash = sha256_hex(canonical.as_bytes());
286 let rollouts_dir = config.release_dir.join("rollouts");
287 for (channel_name, _channel) in resolved.channels.iter() {
288 let manifest = match project_manifest(
289 &resolved,
290 channel_name,
291 &fleet_resolved_hash,
292 signed_at,
293 ci_commit.as_deref(),
294 &config.signature_algorithm,
295 )? {
296 Some(m) => m,
297 None => continue,
298 };
299
300 let manifest_json = serde_json::to_string(&manifest)
301 .with_context(|| format!("serialise manifest for channel {channel_name}"))?;
302 let manifest_canonical = nixfleet_canonicalize::canonicalize(&manifest_json)
303 .with_context(|| format!("canonicalize manifest for channel {channel_name}"))?;
304 let rollout_id = RolloutId::new(&manifest.channel, &manifest.channel_ref)
310 .as_str()
311 .to_string();
312
313 let artifact_name = format!("{rollout_id}.json");
314 let manifest_path = rollouts_dir.join(&artifact_name);
315 let sig_path = rollouts_dir.join(format!("{artifact_name}.sig"));
316
317 let sig_bytes = if manifest_path.exists()
320 && sig_path.exists()
321 && std::fs::read(&manifest_path).ok().as_deref() == Some(manifest_canonical.as_bytes())
322 {
323 std::fs::read(&sig_path).context("read existing manifest signature")?
324 } else {
325 sign(&config.sign_cmd, manifest_canonical.as_bytes())?
326 };
327
328 write_release(
329 &rollouts_dir,
330 &artifact_name,
331 manifest_canonical.as_bytes(),
332 &sig_bytes,
333 )?;
334 manifest_paths.push(manifest_path);
335 manifest_paths.push(sig_path);
336
337 tracing::info!(
338 target: "nixfleet_release",
339 rollout_id = %rollout_id,
340 channel = %channel_name,
341 host_count = manifest.host_set.len(),
342 "rollout manifest signed + written",
343 );
344 }
345
346 let mut commit_sha = None;
347 if config.git_commit {
348 let mut release_files = vec![release_path.clone(), signature_path.clone()];
349 release_files.extend(revocations_paths.iter().cloned());
350 release_files.extend(bootstrap_nonces_paths.iter().cloned());
351 release_files.extend(manifest_paths.iter().cloned());
352 let committed =
353 git_commit_release(config, &release_files, ci_commit.as_deref(), signed_at)?;
354 if let Some(c) = &config.git_push {
355 if committed {
356 git_push_release(&config.flake_dir, c)?;
357 } else {
358 tracing::info!("no release change - skip push");
359 }
360 }
361 commit_sha = if committed {
362 git_head_sha(&config.flake_dir).ok()
363 } else {
364 None
365 };
366 if !committed && preserved_signed_at.is_some() {
367 return Ok(RunOutcome::NoChange);
368 }
369 }
370
371 let host_names: Vec<String> = hashes.keys().cloned().collect();
372 Ok(RunOutcome::Released {
373 commit_sha,
374 hosts: host_names,
375 })
376}
377
378fn validate_config(c: &ReleaseConfig) -> Result<()> {
379 match c.signature_algorithm.as_str() {
380 "ed25519" | "ecdsa-p256" => {}
381 other => bail!("--signature-algorithm must be 'ed25519' or 'ecdsa-p256', got '{other}'"),
382 }
383 if c.git_push.is_some() && !c.git_commit {
384 bail!("--git-push requires --git-commit");
385 }
386 if c.sign_cmd.trim().is_empty() {
387 bail!("--sign-cmd is required and cannot be empty");
388 }
389 Ok(())
390}
391
392fn enumerate_hosts(config: &ReleaseConfig) -> Result<Vec<(String, HostKind)>> {
395 let mut nixos = list_attr_optional(&config.flake_dir, "nixosConfigurations")?;
396 nixos.sort();
397 nixos.dedup();
398 let mut darwin = list_attr_optional(&config.flake_dir, "darwinConfigurations")?;
399 darwin.sort();
400 darwin.dedup();
401
402 let in_nixos = |n: &str| nixos.iter().any(|h| h == n);
403 let in_darwin = |n: &str| darwin.iter().any(|h| h == n);
404
405 Ok(match &config.hosts {
406 HostsSpec::Auto => nixos
407 .iter()
408 .map(|n| (n.clone(), HostKind::Nixos))
409 .chain(darwin.iter().map(|n| (n.clone(), HostKind::Darwin)))
410 .collect(),
411 HostsSpec::AutoExclude(exclude) => {
412 let kept_nixos = nixos
413 .iter()
414 .filter(|h| !exclude.iter().any(|e| e == *h))
415 .map(|n| (n.clone(), HostKind::Nixos));
416 let kept_darwin = darwin
417 .iter()
418 .filter(|h| !exclude.iter().any(|e| e == *h))
419 .map(|n| (n.clone(), HostKind::Darwin));
420 kept_nixos.chain(kept_darwin).collect()
421 }
422 HostsSpec::Explicit(list) => list
423 .iter()
424 .map(|n| match (in_nixos(n), in_darwin(n)) {
425 (true, false) => Ok((n.clone(), HostKind::Nixos)),
426 (false, true) => Ok((n.clone(), HostKind::Darwin)),
427 (true, true) => Err(anyhow::anyhow!(
428 "host '{n}' is declared in both nixosConfigurations and \
429 darwinConfigurations - disambiguate before releasing"
430 )),
431 (false, false) => Err(anyhow::anyhow!(
432 "host '{n}' is in neither nixosConfigurations nor \
433 darwinConfigurations of flake {}",
434 config.flake_dir.display()
435 )),
436 })
437 .collect::<Result<Vec<_>>>()?,
438 })
439}
440
441fn sha256_hex(bytes: &[u8]) -> String {
442 hex::encode(Sha256::digest(bytes))
443}
444
445pub(crate) fn prune_expired_bootstrap_nonces(
449 entries: Vec<nixfleet_proto::BootstrapNonceEntry>,
450 signed_at: DateTime<Utc>,
451) -> Vec<nixfleet_proto::BootstrapNonceEntry> {
452 entries
453 .into_iter()
454 .filter(|e| e.expires_at >= signed_at)
455 .collect()
456}
457
458fn eval_revocations(config: &ReleaseConfig, attr: &str) -> Result<Vec<RevocationEntry>> {
459 let output = Command::new("nix")
460 .args(["eval", "--json", "--no-warn-dirty", &format!(".#{attr}")])
461 .current_dir(&config.flake_dir)
462 .output()
463 .with_context(|| format!("invoke `nix eval .#{attr}`"))?;
464 if !output.status.success() {
465 bail!(
466 "nix eval .#{attr}: {}",
467 String::from_utf8_lossy(&output.stderr)
468 );
469 }
470 serde_json::from_slice(&output.stdout)
471 .with_context(|| format!("parse revocations from `nix eval .#{attr}`"))
472}
473
474fn eval_bootstrap_nonces(
475 config: &ReleaseConfig,
476 attr: &str,
477) -> Result<Vec<nixfleet_proto::BootstrapNonceEntry>> {
478 let output = Command::new("nix")
479 .args(["eval", "--json", "--no-warn-dirty", &format!(".#{attr}")])
480 .current_dir(&config.flake_dir)
481 .output()
482 .with_context(|| format!("invoke `nix eval .#{attr}`"))?;
483 if !output.status.success() {
484 bail!(
485 "nix eval .#{attr}: {}",
486 String::from_utf8_lossy(&output.stderr)
487 );
488 }
489 serde_json::from_slice(&output.stdout)
490 .with_context(|| format!("parse bootstrap nonces from `nix eval .#{attr}`"))
491}
492
493fn list_attr_optional(flake_dir: &Path, attr_path: &str) -> Result<Vec<String>> {
496 let output = Command::new("nix")
497 .args([
498 "eval",
499 "--json",
500 "--no-warn-dirty",
501 &format!(".#{attr_path}"),
502 "--apply",
503 "builtins.attrNames",
504 ])
505 .current_dir(flake_dir)
506 .output()
507 .with_context(|| format!("invoke `nix eval .#{attr_path}`"))?;
508 if !output.status.success() {
509 let stderr = String::from_utf8_lossy(&output.stderr);
510 let lowered = stderr.to_lowercase();
511 let is_missing = [
512 "does not provide attribute",
513 "has no attribute",
514 "attribute 'darwinconfigurations' missing",
515 "attribute 'nixosconfigurations' missing",
516 ]
517 .iter()
518 .any(|needle| lowered.contains(needle));
519 if is_missing {
520 tracing::debug!(
521 attr_path,
522 "flake does not declare {attr_path}; treating as empty"
523 );
524 return Ok(vec![]);
525 }
526 bail!("nix eval .#{attr_path}: {stderr}");
527 }
528 let names: Vec<String> = serde_json::from_slice(&output.stdout)
529 .with_context(|| format!("parse JSON from `nix eval .#{attr_path}`"))?;
530 Ok(names)
531}
532
533fn build_hosts(
538 config: &ReleaseConfig,
539 hosts: &[(String, HostKind)],
540 resolved: &FleetResolved,
541 current_commit: Option<&str>,
542) -> Result<BTreeMap<String, PathBuf>> {
543 let mut out = BTreeMap::new();
544 for (host, kind) in hosts {
545 let pinned_commit = pin_target_commit(resolved, host, current_commit);
546 let path = match pinned_commit {
547 None => {
548 let attr = format!(
549 ".#{}.{host}.config.system.build.toplevel",
550 kind.attr_prefix()
551 );
552 build_local(&config.flake_dir, &attr)
553 .with_context(|| format!("build host {host}"))?
554 }
555 Some(commit) => {
556 let url = config.pin_source_url.as_deref().ok_or_else(|| {
557 anyhow::anyhow!(
560 "host '{host}' is pinned to commit '{commit}' but \
561 --pin-source-url is unset"
562 )
563 })?;
564 build_pinned(url, commit, *kind, host)
565 .with_context(|| format!("build pinned host {host} @ {commit}"))?
566 }
567 };
568 tracing::info!(host = %host, kind = ?kind, path, "built");
569 out.insert(host.clone(), PathBuf::from(path));
570 }
571 Ok(out)
572}
573
574fn pin_target_commit<'a>(
577 resolved: &'a FleetResolved,
578 host: &str,
579 current_commit: Option<&str>,
580) -> Option<&'a str> {
581 let pin = resolved.hosts.get(host)?.pin.as_ref()?;
582 if Some(pin.commit.as_str()) == current_commit {
583 return None;
584 }
585 Some(pin.commit.as_str())
586}
587
588fn build_local(flake_dir: &Path, attr: &str) -> Result<String> {
589 let output = Command::new("nix")
590 .args([
591 "build",
592 "--no-link",
593 "--print-out-paths",
594 "--no-warn-dirty",
595 attr,
596 ])
597 .current_dir(flake_dir)
598 .output()
599 .with_context(|| format!("invoke `nix build {attr}`"))?;
600 interpret_build_output(attr, output)
601}
602
603fn build_pinned(pin_source_url: &str, commit: &str, kind: HostKind, host: &str) -> Result<String> {
606 let attr = format!(
607 "{pin_source_url}?rev={commit}#{}.{host}.config.system.build.toplevel",
608 kind.attr_prefix()
609 );
610 tracing::info!(
611 host = %host,
612 commit = %commit,
613 url = %pin_source_url,
614 "building pinned host via flake-ref",
615 );
616 let output = Command::new("nix")
617 .args([
618 "build",
619 "--no-link",
620 "--print-out-paths",
621 "--no-warn-dirty",
622 &attr,
623 ])
624 .output()
625 .with_context(|| format!("invoke `nix build {attr}`"))?;
626 interpret_build_output(&attr, output)
627}
628
629fn interpret_build_output(attr: &str, output: std::process::Output) -> Result<String> {
630 if !output.status.success() {
631 bail!(
632 "nix build {attr}: {}",
633 String::from_utf8_lossy(&output.stderr)
634 );
635 }
636 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
637 if path.is_empty() {
638 bail!("nix build {attr}: empty output");
639 }
640 Ok(path)
641}
642
643pub fn filter_expired_pins(resolved: &mut FleetResolved, now: DateTime<Utc>) {
646 for (host_name, host) in resolved.hosts.iter_mut() {
647 if let Some(pin) = host.pin.as_ref()
648 && let Some(expires) = pin.expires_at
649 && expires <= now
650 {
651 tracing::info!(
652 host = %host_name,
653 expired_at = %expires,
654 commit = %pin.commit,
655 "pin expired - falling back to current-commit build",
656 );
657 host.pin = None;
658 }
659 }
660}
661
662fn validate_pin_source_url(
665 config: &ReleaseConfig,
666 resolved: &FleetResolved,
667 current_commit: Option<&str>,
668) -> Result<()> {
669 if config.pin_source_url.is_some() {
670 return Ok(());
671 }
672 let needs: Vec<&str> = resolved
673 .hosts
674 .iter()
675 .filter_map(|(name, host)| {
676 host.pin.as_ref().and_then(|p| {
677 if Some(p.commit.as_str()) != current_commit {
678 Some(name.as_str())
679 } else {
680 None
681 }
682 })
683 })
684 .collect();
685 if !needs.is_empty() {
686 bail!(
687 "--pin-source-url is required: hosts with non-current-commit pins ({}) \
688 can't be built without a source URL",
689 needs.join(", ")
690 );
691 }
692 Ok(())
693}
694
695fn closure_hash(path: &Path) -> String {
696 path.file_name()
697 .and_then(|s| s.to_str())
698 .unwrap_or_default()
699 .to_string()
700}
701
702fn push_one(cmd: &str, host: &str, path: &Path, closure_hash: &str) -> Result<()> {
703 tracing::info!(host = %host, "push hook");
704 let status = Command::new("sh")
705 .arg("-c")
706 .arg(cmd)
707 .env("NIXFLEET_HOST", host)
708 .env("NIXFLEET_PATH", path)
709 .env("NIXFLEET_CLOSURE_HASH", closure_hash)
710 .status()
711 .with_context(|| format!("invoke push hook for {host}"))?;
712 if !status.success() {
713 bail!(
714 "push hook for {host} exited {} ({:?})",
715 status.code().unwrap_or(-1),
716 cmd,
717 );
718 }
719 Ok(())
720}
721
722pub(crate) fn eval_fleet_resolved(config: &ReleaseConfig) -> Result<FleetResolved> {
723 let output = Command::new("nix")
724 .args([
725 "eval",
726 "--json",
727 "--no-warn-dirty",
728 &config.fleet_resolved_attr,
729 ])
730 .current_dir(&config.flake_dir)
731 .output()
732 .with_context(|| format!("invoke `nix eval {}`", config.fleet_resolved_attr))?;
733 if !output.status.success() {
734 bail!(
735 "nix eval {}: {}",
736 config.fleet_resolved_attr,
737 String::from_utf8_lossy(&output.stderr)
738 );
739 }
740 let resolved: FleetResolved = serde_json::from_slice(&output.stdout)
741 .with_context(|| format!("parse {} as FleetResolved", config.fleet_resolved_attr))?;
742 Ok(resolved)
743}
744
745pub fn inject_closure_hashes(resolved: &mut FleetResolved, hashes: &BTreeMap<String, String>) {
747 for (host, hash) in hashes {
748 if let Some(h) = resolved.hosts.get_mut(host) {
749 h.closure_hash = Some(hash.clone());
750 }
751 }
752}
753
754pub fn stamp_meta(
755 resolved: &mut FleetResolved,
756 signed_at: DateTime<Utc>,
757 ci_commit: Option<String>,
758 signature_algorithm: &str,
759) {
760 resolved.meta.signed_at = Some(signed_at);
761 resolved.meta.ci_commit = ci_commit;
762 resolved.meta.signature_algorithm = Some(signature_algorithm.to_string());
763}
764
765pub fn canonicalize_resolved(resolved: &FleetResolved) -> Result<String> {
766 let raw =
767 serde_json::to_string(resolved).context("serialize FleetResolved before canonicalize")?;
768 nixfleet_canonicalize::canonicalize(&raw).context("canonicalize fleet.resolved")
769}
770
771fn load_existing_signed_at_if_unchanged(
773 release_path: &Path,
774 resolved: &FleetResolved,
775) -> Result<Option<DateTime<Utc>>> {
776 if !release_path.exists() {
777 return Ok(None);
778 }
779 let raw = std::fs::read_to_string(release_path)
780 .with_context(|| format!("read existing release {}", release_path.display()))?;
781 let existing: FleetResolved =
782 serde_json::from_str(&raw).context("parse existing release file")?;
783
784 let cur_hashes: BTreeMap<&str, Option<&str>> = resolved
785 .hosts
786 .iter()
787 .map(|(k, v)| (k.as_str(), v.closure_hash.as_deref()))
788 .collect();
789 let prev_hashes: BTreeMap<&str, Option<&str>> = existing
790 .hosts
791 .iter()
792 .map(|(k, v)| (k.as_str(), v.closure_hash.as_deref()))
793 .collect();
794
795 if cur_hashes == prev_hashes {
796 Ok(existing.meta.signed_at)
797 } else {
798 Ok(None)
799 }
800}
801
802#[cfg(test)]
803mod bootstrap_nonces_tests {
804 use super::*;
805 use nixfleet_proto::BootstrapNonceEntry;
806
807 fn entry(nonce: &str, expires_at: &str) -> BootstrapNonceEntry {
808 BootstrapNonceEntry {
809 nonce: nonce.into(),
810 hostname: "agent-01".into(),
811 expires_at: expires_at.parse().unwrap(),
812 minted_at: None,
813 minted_by: None,
814 }
815 }
816
817 #[test]
818 fn prune_drops_entries_with_expires_at_before_signed_at() {
819 let signed_at: DateTime<Utc> = "2026-05-13T10:00:00Z".parse().unwrap();
820 let entries = vec![
821 entry("expired", "2026-05-12T10:00:00Z"),
822 entry("fresh", "2026-05-14T10:00:00Z"),
823 entry("exactly-now", "2026-05-13T10:00:00Z"),
824 ];
825 let kept = prune_expired_bootstrap_nonces(entries, signed_at);
826 let nonces: Vec<&str> = kept.iter().map(|e| e.nonce.as_str()).collect();
827 assert_eq!(nonces, vec!["fresh", "exactly-now"]);
831 }
832
833 #[test]
834 fn prune_empty_list_is_empty() {
835 let signed_at: DateTime<Utc> = "2026-05-13T10:00:00Z".parse().unwrap();
836 let kept = prune_expired_bootstrap_nonces(vec![], signed_at);
837 assert!(kept.is_empty());
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use nixfleet_proto::{Channel, Host, Meta};
845
846 fn dummy_resolved() -> FleetResolved {
847 let mut hosts = std::collections::HashMap::new();
848 hosts.insert(
849 "test-host".to_string(),
850 Host {
851 system: "x86_64-linux".into(),
852 tags: vec![],
853 channel: "stable".into(),
854 closure_hash: None,
855 pubkey: None,
856 pin: None,
857 },
858 );
859 hosts.insert(
860 "host-03".to_string(),
861 Host {
862 system: "aarch64-darwin".into(),
863 tags: vec![],
864 channel: "stable".into(),
865 closure_hash: None,
866 pubkey: None,
867 pin: None,
868 },
869 );
870 let mut channels = std::collections::HashMap::new();
871 channels.insert(
872 "stable".to_string(),
873 Channel {
874 rollout_policy: "default".into(),
875 reconcile_interval_minutes: 5,
876 freshness_window: 60,
877 signing_interval_minutes: 30,
878 },
879 );
880 FleetResolved {
881 schema_version: 1,
882 hosts,
883 channels,
884 rollout_policies: Default::default(),
885 waves: Default::default(),
886 edges: vec![],
887 channel_edges: vec![],
888 disruption_budgets: vec![],
889 meta: Meta {
890 schema_version: 1,
891 signed_at: None,
892 ci_commit: None,
893 signature_algorithm: Some("ed25519".into()),
894 },
895 }
896 }
897
898 #[test]
899 fn inject_sets_closure_hash_for_known_hosts_and_skips_unknown() {
900 let mut r = dummy_resolved();
901 let mut hashes = BTreeMap::new();
902 hashes.insert(
903 "test-host".to_string(),
904 "abc123-nixos-system-test-host".to_string(),
905 );
906 hashes.insert("ghost".to_string(), "should-be-ignored".to_string());
907 inject_closure_hashes(&mut r, &hashes);
908 assert_eq!(
909 r.hosts["test-host"].closure_hash.as_deref(),
910 Some("abc123-nixos-system-test-host")
911 );
912 assert!(r.hosts["host-03"].closure_hash.is_none());
913 assert!(!r.hosts.contains_key("ghost"));
914 }
915
916 #[test]
917 fn stamp_meta_writes_three_fields() {
918 let mut r = dummy_resolved();
919 let ts = DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
920 .unwrap()
921 .with_timezone(&Utc);
922 stamp_meta(&mut r, ts, Some("deadbeef".into()), "ed25519");
923 assert_eq!(r.meta.signed_at, Some(ts));
924 assert_eq!(r.meta.ci_commit.as_deref(), Some("deadbeef"));
925 assert_eq!(r.meta.signature_algorithm.as_deref(), Some("ed25519"));
926 }
927
928 #[test]
929 fn canonicalize_round_trip_is_byte_stable() {
930 let mut r = dummy_resolved();
931 let ts = DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
932 .unwrap()
933 .with_timezone(&Utc);
934 stamp_meta(&mut r, ts, Some("deadbeef".into()), "ed25519");
935 let c1 = canonicalize_resolved(&r).unwrap();
936 let parsed: FleetResolved = serde_json::from_str(&c1).unwrap();
937 let c2 = canonicalize_resolved(&parsed).unwrap();
938 assert_eq!(c1, c2);
939 }
940
941 #[test]
942 fn render_commit_message_substitutes() {
943 let ts = DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
944 .unwrap()
945 .with_timezone(&Utc);
946 let m = render_commit_message(
947 "chore(ci): release {sha:0:8} [skip ci]",
948 "deadbeefcafebabe",
949 ts,
950 );
951 assert_eq!(m, "chore(ci): release deadbeef [skip ci]");
952
953 let m2 = render_commit_message("ts={ts}, sha={sha}", "abc", ts);
954 assert_eq!(m2, "ts=2026-04-27T12:00:00+00:00, sha=abc");
955 }
956
957 fn manifest_resolved() -> FleetResolved {
958 use nixfleet_proto::{HealthGate, PolicyWave, RolloutPolicy, Selector, Wave};
959 let mut hosts = std::collections::HashMap::new();
960 hosts.insert(
961 "agent-02".to_string(),
962 Host {
963 system: "x86_64-linux".into(),
964 tags: vec![],
965 channel: "stable".into(),
966 closure_hash: Some("aaaa-host-b".into()),
967 pubkey: None,
968 pin: None,
969 },
970 );
971 hosts.insert(
972 "agent-01".to_string(),
973 Host {
974 system: "x86_64-linux".into(),
975 tags: vec![],
976 channel: "stable".into(),
977 closure_hash: Some("aaaa-host-a".into()),
978 pubkey: None,
979 pin: None,
980 },
981 );
982 hosts.insert(
983 "agent-no-closure".to_string(),
984 Host {
985 system: "x86_64-linux".into(),
986 tags: vec![],
987 channel: "stable".into(),
988 closure_hash: None,
989 pubkey: None,
990 pin: None,
991 },
992 );
993 let mut channels = std::collections::HashMap::new();
994 channels.insert(
995 "stable".to_string(),
996 Channel {
997 rollout_policy: "default".into(),
998 reconcile_interval_minutes: 5,
999 freshness_window: 60,
1000 signing_interval_minutes: 30,
1001 },
1002 );
1003 let mut rollout_policies = std::collections::HashMap::new();
1004 rollout_policies.insert(
1005 "default".to_string(),
1006 RolloutPolicy {
1007 strategy: "waves".into(),
1008 waves: vec![PolicyWave {
1009 selector: Selector {
1010 tags: vec![],
1011 tags_any: vec![],
1012 hosts: vec![],
1013 channel: None,
1014 all: true,
1015 },
1016 soak_minutes: 5,
1017 }],
1018 health_gate: HealthGate::default(),
1019 on_health_failure: nixfleet_proto::OnHealthFailure::Halt,
1020 },
1021 );
1022 let mut waves = std::collections::HashMap::new();
1023 waves.insert(
1024 "stable".to_string(),
1025 vec![
1026 Wave {
1027 hosts: vec!["agent-01".into()],
1028 soak_minutes: 5,
1029 },
1030 Wave {
1031 hosts: vec!["agent-02".into()],
1032 soak_minutes: 5,
1033 },
1034 ],
1035 );
1036 FleetResolved {
1037 schema_version: 1,
1038 hosts,
1039 channels,
1040 rollout_policies,
1041 waves,
1042 edges: vec![],
1043 channel_edges: vec![],
1044 disruption_budgets: vec![],
1045 meta: Meta {
1046 schema_version: 1,
1047 signed_at: None,
1048 ci_commit: None,
1049 signature_algorithm: Some("ed25519".into()),
1050 },
1051 }
1052 }
1053
1054 #[test]
1055 fn project_manifest_emits_sorted_host_set_with_correct_wave_indices() {
1056 let r = manifest_resolved();
1057 let ts = DateTime::parse_from_rfc3339("2026-04-30T12:00:00Z")
1058 .unwrap()
1059 .with_timezone(&Utc);
1060 let m = project_manifest(&r, "stable", "feedface", ts, Some("def45678"), "ed25519")
1061 .unwrap()
1062 .expect("non-empty manifest");
1063 assert_eq!(m.host_set[0].hostname, "agent-01");
1064 assert_eq!(m.host_set[1].hostname, "agent-02");
1065 assert_eq!(m.host_set.len(), 2);
1066 assert_eq!(m.host_set[0].wave_index, 0);
1067 assert_eq!(m.host_set[1].wave_index, 1);
1068 assert_eq!(m.host_set[0].target_closure, "aaaa-host-a");
1069 assert_eq!(m.host_set[1].target_closure, "aaaa-host-b");
1070 assert_eq!(m.fleet_resolved_hash, "feedface");
1071 assert_eq!(m.display_name, "stable@def45678");
1072 assert_eq!(m.channel_ref, "def45678");
1073 assert_eq!(m.meta.signed_at, Some(ts));
1074 }
1075
1076 #[test]
1077 fn project_manifest_returns_none_when_no_host_has_closure_hash() {
1078 let r = dummy_resolved();
1079 let mut r = r;
1080 r.rollout_policies.insert(
1081 "default".to_string(),
1082 nixfleet_proto::RolloutPolicy {
1083 strategy: "waves".into(),
1084 waves: vec![],
1085 health_gate: nixfleet_proto::HealthGate::default(),
1086 on_health_failure: nixfleet_proto::OnHealthFailure::Halt,
1087 },
1088 );
1089 let ts = Utc::now();
1090 let result = project_manifest(&r, "stable", "deadbeef", ts, None, "ed25519").unwrap();
1091 assert!(result.is_none());
1092 }
1093
1094 #[test]
1095 fn project_manifest_errors_on_missing_channel() {
1096 let r = manifest_resolved();
1097 let ts = Utc::now();
1098 let err = project_manifest(&r, "ghost", "feedface", ts, None, "ed25519").unwrap_err();
1099 assert!(err.to_string().contains("channel ghost"));
1100 }
1101
1102 #[test]
1103 fn sha256_hex_is_64_char_lowercase() {
1104 let h = sha256_hex(b"hello world");
1105 assert_eq!(h.len(), 64);
1106 assert!(
1107 h.chars()
1108 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1109 );
1110 assert_eq!(
1111 h,
1112 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1113 );
1114 }
1115
1116 #[test]
1117 fn validate_rejects_bad_algorithm() {
1118 let mut c = base_config();
1119 c.signature_algorithm = "rsa".into();
1120 let err = validate_config(&c).unwrap_err();
1121 assert!(err.to_string().contains("signature-algorithm"));
1122 }
1123
1124 #[test]
1125 fn validate_rejects_push_without_commit() {
1126 let mut c = base_config();
1127 c.git_push = Some(GitPushTarget {
1128 remote: "origin".into(),
1129 branch: "main".into(),
1130 });
1131 c.git_commit = false;
1132 let err = validate_config(&c).unwrap_err();
1133 assert!(err.to_string().contains("--git-commit"));
1134 }
1135
1136 fn base_config() -> ReleaseConfig {
1137 ReleaseConfig {
1138 flake_dir: PathBuf::from("."),
1139 fleet_resolved_attr: ".#fleet.resolved".into(),
1140 hosts: HostsSpec::Auto,
1141 push_cmd: None,
1142 sign_cmd: "true".into(),
1143 signature_algorithm: "ed25519".into(),
1144 release_dir: PathBuf::from("releases"),
1145 artifact_name: "fleet.resolved.json".into(),
1146 git_commit: false,
1147 git_push: None,
1148 commit_template: "release {sha:0:8}".into(),
1149 git_user_name: None,
1150 git_user_email: None,
1151 smoke_verify: true,
1152 reuse_unchanged_signature: false,
1153 revocations_attr: None,
1154 bootstrap_nonces_attr: None,
1155 pin_source_url: None,
1156 }
1157 }
1158
1159 #[test]
1160 fn host_kind_attr_prefix_matches_flake_convention() {
1161 assert_eq!(HostKind::Nixos.attr_prefix(), "nixosConfigurations");
1162 assert_eq!(HostKind::Darwin.attr_prefix(), "darwinConfigurations");
1163 }
1164
1165 fn pin_resolved(host_pin: Option<nixfleet_proto::Pin>) -> FleetResolved {
1166 let mut r = dummy_resolved();
1167 r.hosts.get_mut("test-host").unwrap().pin = host_pin;
1168 r
1169 }
1170
1171 fn fresh_pin(commit: &str, expires_at: Option<DateTime<Utc>>) -> nixfleet_proto::Pin {
1172 nixfleet_proto::Pin {
1173 commit: commit.to_string(),
1174 reason: "test".to_string(),
1175 expires_at,
1176 }
1177 }
1178
1179 #[test]
1180 fn filter_expired_drops_past_expiry_and_keeps_future() {
1181 let now = Utc::now();
1182 let past = now - chrono::Duration::days(1);
1183 let future = now + chrono::Duration::days(1);
1184
1185 let mut r = pin_resolved(Some(fresh_pin("c1", Some(past))));
1186 filter_expired_pins(&mut r, now);
1187 assert!(
1188 r.hosts["test-host"].pin.is_none(),
1189 "expired pin must be dropped",
1190 );
1191
1192 let mut r = pin_resolved(Some(fresh_pin("c2", Some(future))));
1193 filter_expired_pins(&mut r, now);
1194 assert!(r.hosts["test-host"].pin.is_some(), "fresh pin must survive",);
1195
1196 let mut r = pin_resolved(Some(fresh_pin("c3", None)));
1197 filter_expired_pins(&mut r, now);
1198 assert!(
1199 r.hosts["test-host"].pin.is_some(),
1200 "pin with no expiry must always survive",
1201 );
1202 }
1203
1204 #[test]
1205 fn filter_expired_treats_exact_now_as_expired() {
1206 let now = Utc::now();
1208 let mut r = pin_resolved(Some(fresh_pin("c", Some(now))));
1209 filter_expired_pins(&mut r, now);
1210 assert!(r.hosts["test-host"].pin.is_none());
1211 }
1212
1213 #[test]
1214 fn pin_target_commit_none_for_unpinned_host() {
1215 let r = pin_resolved(None);
1216 assert!(pin_target_commit(&r, "test-host", Some("abc")).is_none());
1217 }
1218
1219 #[test]
1220 fn pin_target_commit_none_when_pin_matches_release_commit() {
1221 let r = pin_resolved(Some(fresh_pin("abc1234", None)));
1222 assert!(
1223 pin_target_commit(&r, "test-host", Some("abc1234")).is_none(),
1224 "same-commit pin must hit the local build path, not flake-ref",
1225 );
1226 }
1227
1228 #[test]
1229 fn pin_target_commit_some_when_pin_diverges_from_release_commit() {
1230 let r = pin_resolved(Some(fresh_pin("frozen-abc", None)));
1231 assert_eq!(
1232 pin_target_commit(&r, "test-host", Some("current-def")),
1233 Some("frozen-abc"),
1234 );
1235 }
1236
1237 #[test]
1238 fn validate_pin_source_url_errors_when_needed_and_unset() {
1239 let mut c = base_config();
1240 c.pin_source_url = None;
1241 let r = pin_resolved(Some(fresh_pin("frozen-abc", None)));
1242 let err = validate_pin_source_url(&c, &r, Some("current-def")).unwrap_err();
1243 let msg = format!("{err:#}");
1244 assert!(
1245 msg.contains("--pin-source-url is required"),
1246 "error must mention the missing flag: {msg}",
1247 );
1248 assert!(
1249 msg.contains("test-host"),
1250 "error must list the offending host: {msg}",
1251 );
1252 }
1253
1254 #[test]
1255 fn validate_pin_source_url_ok_when_no_pins() {
1256 let c = base_config();
1257 let r = dummy_resolved();
1258 validate_pin_source_url(&c, &r, Some("any-commit")).unwrap();
1259 }
1260
1261 #[test]
1262 fn validate_pin_source_url_ok_when_pin_matches_release_commit() {
1263 let c = base_config();
1264 let r = pin_resolved(Some(fresh_pin("matching-commit", None)));
1265 validate_pin_source_url(&c, &r, Some("matching-commit")).unwrap();
1266 }
1267
1268 #[test]
1269 fn validate_pin_source_url_ok_when_url_is_set() {
1270 let mut c = base_config();
1271 c.pin_source_url = Some("git+ssh://example/fleet".into());
1272 let r = pin_resolved(Some(fresh_pin("frozen-abc", None)));
1273 validate_pin_source_url(&c, &r, Some("current-def")).unwrap();
1274 }
1275}