meta: commit before lock — git+file:// only sees tracked files

runtime error on first deploy attempt: 'source tree referenced
by git+file:///var/lib/hyperhive/meta does not contain
/flake.nix'. cause: sync_agents wrote flake.nix then ran
'nix flake lock' against a directory nix had just discovered
as a git repo (auto-upgraded to git+file://), which only sees
TRACKED content. fresh flake.nix was untracked, so nix saw an
empty source tree.

fix: commit flake.nix before locking. sync_agents now does
write → init (if first) → git add + commit → nix flake lock
→ commit lock if changed. two commits per change — one
'regenerate meta flake' and one 'lock update' — instead of
one combined; cleaner history.

same git+file:// gotcha bit the two-phase deploy: prepare_
deploy used to write the lock without committing, expecting
nixos-container update to read the working tree. it doesn't —
it reads the tracked commit. prepare_deploy now commits with
a placeholder 'deploy <n> (building)' message; finalize_deploy
amends to 'deploy <n> deployed/<id> <sha12>' on success;
abort_deploy git-reset --hard HEAD~1's it on failure. meta
history still records only successful deploys.
This commit is contained in:
müde 2026-05-16 00:59:35 +02:00
parent d94712bde8
commit 220e9b4af6

View file

@ -73,50 +73,110 @@ pub async fn sync_agents(
if initial { if initial {
git(&dir, &["init", "--initial-branch=main"]).await?; git(&dir, &["init", "--initial-branch=main"]).await?;
} }
nix(&dir, &["flake", "lock"]).await?; // Commit flake.nix *before* running nix flake lock — when meta is
git(&dir, &["add", "-A"]).await?; // a git repo, nix treats it as a `git+file://` self-reference and
// only sees TRACKED files. Locking against an untracked flake.nix
// surfaces as "source tree does not contain '/flake.nix'".
git(&dir, &["add", "flake.nix"]).await?;
let msg = if initial { let msg = if initial {
format!("seed meta from {} agent(s)", agents.len()) format!("seed meta from {} agent(s)", agents.len())
} else { } else {
"regenerate meta flake".to_owned() "regenerate meta flake".to_owned()
}; };
git_commit(&dir, &msg).await?; git_commit(&dir, &msg).await?;
nix(&dir, &["flake", "lock"]).await?;
if !git_is_clean(&dir).await? {
git(&dir, &["add", "flake.lock"]).await?;
git_commit(&dir, "lock update").await?;
}
Ok(()) Ok(())
} }
/// Phase 1 of an apply-commit deploy. Updates the locked rev of /// Phase 1 of an apply-commit deploy. Updates the locked rev of
/// `agent-<name>` to whatever `applied/<name>/main` currently points /// `agent-<name>` to whatever `applied/<name>/main` currently points
/// at. **Doesn't commit** — caller must follow with /// at **and commits** the bump immediately — `git+file://` semantics
/// `finalize_deploy` on build success or `abort_deploy` on failure. /// mean nixos-container would otherwise build against the previously
/// tracked lock, ignoring the working-tree change. `finalize_deploy`
/// later amends the message with the deployed tag; `abort_deploy`
/// drops the commit entirely so meta history shows only successful
/// deploys.
#[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit #[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit
pub async fn prepare_deploy(name: &str) -> Result<()> { pub async fn prepare_deploy(name: &str) -> Result<()> {
let dir = meta_dir(); let dir = meta_dir();
let input = format!("agent-{name}"); let input = format!("agent-{name}");
nix(&dir, &["flake", "update", &input]).await nix(&dir, &["flake", "update", &input]).await?;
}
/// Phase 2-success. Commits the staged `flake.lock` change with a
/// deploy-shaped message. No-op (clean working tree) is tolerated —
/// some lock-updates resolve to the same rev that's already locked.
#[allow(dead_code)]
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
let dir = meta_dir();
if git_is_clean(&dir).await? { if git_is_clean(&dir).await? {
// Lock unchanged (rev already matches). Nothing to commit;
// finalize_deploy will be a no-op too.
return Ok(()); return Ok(());
} }
git(&dir, &["add", "flake.lock"]).await?; git(&dir, &["add", "flake.lock"]).await?;
let short = &sha[..sha.len().min(12)]; git_commit(&dir, &format!("deploy {name} (building)")).await
git_commit(&dir, &format!("deploy {name} {tag} {short}")).await
} }
/// Phase 2-failure. Drops the uncommitted `flake.lock` change so meta /// Phase 2-success. Amend the prepare-deploy commit's message with
/// stays pinned at the previously-deployed shas. The failed proposal /// the canonical deployed tag + sha. No-op when prepare didn't
/// is still captured in `applied/<n>`'s annotated `failed/<id>` tag — /// commit (input was already at the right rev).
/// meta's history only carries successful deploys. #[allow(dead_code)]
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
let dir = meta_dir();
// Detect prepare's commit by its placeholder message; if HEAD is
// some other commit (e.g. prepare no-op'd, or a concurrent change
// landed) just add a fresh commit instead of amending.
let head_msg = git_head_msg(&dir).await.unwrap_or_default();
let short = &sha[..sha.len().min(12)];
let new_msg = format!("deploy {name} {tag} {short}");
if head_msg.starts_with(&format!("deploy {name} (building)")) {
git(
&dir,
&[
"-c",
&format!("user.name={GIT_NAME}"),
"-c",
&format!("user.email={GIT_EMAIL}"),
"commit",
"--amend",
"-m",
&new_msg,
],
)
.await
} else {
Ok(())
}
}
/// Phase 2-failure. Drop the prepare-deploy commit so meta history
/// only carries successful deploys. The failed proposal is still
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
#[allow(dead_code)] #[allow(dead_code)]
pub async fn abort_deploy() -> Result<()> { pub async fn abort_deploy() -> Result<()> {
let dir = meta_dir(); let dir = meta_dir();
let head_msg = git_head_msg(&dir).await.unwrap_or_default();
if head_msg.starts_with("deploy ") && head_msg.contains("(building)") {
// hard reset drops the commit + its working-tree changes.
git(&dir, &["reset", "--hard", "HEAD~1"]).await
} else {
// Prepare no-op'd (lock unchanged); also restore any lingering
// lock-only changes as a safety belt.
git(&dir, &["restore", "flake.lock"]).await git(&dir, &["restore", "flake.lock"]).await
}
}
async fn git_head_msg(dir: &Path) -> Result<String> {
let out = lifecycle::git_command()
.current_dir(dir)
.args(["log", "-1", "--format=%s"])
.output()
.await
.with_context(|| format!("git log in {}", dir.display()))?;
if !out.status.success() {
bail!(
"git log -1 failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
} }
/// One-shot used by the manual-rebuild path: relock just one /// One-shot used by the manual-rebuild path: relock just one