| 31 |
31 |
|
/// each built whatever `main` was at pull time and the artifacts were filed
|
| 32 |
32 |
|
/// under the daemon host's version. This runs before any target task spawns, so
|
| 33 |
33 |
|
/// a mixed-source release is stopped before a single artifact is built.
|
|
34 |
+ |
///
|
|
35 |
+ |
/// Returns the branch each host was on before it was pinned, for
|
|
36 |
+ |
/// [`restore_branches`] to put back once the build settles. A host that is
|
|
37 |
+ |
/// ALREADY detached has no branch to return to, and is refused: that state means
|
|
38 |
+ |
/// some earlier release never cleaned up, and any commits made in the meantime
|
|
39 |
+ |
/// are sitting on no branch at all.
|
| 34 |
40 |
|
async fn pin_release(
|
| 35 |
41 |
|
state: &AppState,
|
| 36 |
42 |
|
app: &AppId,
|
| 37 |
43 |
|
version: &Version,
|
| 38 |
44 |
|
targets: &[Target],
|
| 39 |
|
- |
) -> Result<()> {
|
|
45 |
+ |
) -> Result<Vec<(String, String)>> {
|
| 40 |
46 |
|
let cfg = state
|
| 41 |
47 |
|
.topo
|
| 42 |
48 |
|
.app(app)
|
| 54 |
60 |
|
}
|
| 55 |
61 |
|
|
| 56 |
62 |
|
let mut shas: Vec<(String, String)> = Vec::new();
|
|
63 |
+ |
let mut branches: Vec<(String, String)> = Vec::new();
|
| 57 |
64 |
|
for host in &hosts {
|
| 58 |
65 |
|
let exec = state
|
| 59 |
66 |
|
.executors
|
| 61 |
68 |
|
.ok_or_else(|| anyhow::anyhow!("no executor for host `{host}`"))?;
|
| 62 |
69 |
|
let mut sink = DiscardSink;
|
| 63 |
70 |
|
|
|
71 |
+ |
// Read the branch BEFORE pinning, while there is still one to read.
|
|
72 |
+ |
let branch_cmd = OpStep::shell(Action::Build, engine::git_current_branch_cmd(&repo));
|
|
73 |
+ |
let out = exec
|
|
74 |
+ |
.run_streaming(&branch_cmd, &mut sink)
|
|
75 |
+ |
.await
|
|
76 |
+ |
.with_context(|| format!("release preflight: reading branch on `{host}`"))?;
|
|
77 |
+ |
let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
78 |
+ |
anyhow::ensure!(
|
|
79 |
+ |
out.status.success() && !branch.is_empty(),
|
|
80 |
+ |
"release preflight: `{repo}` on `{host}` is on a detached HEAD, so there is \
|
|
81 |
+ |
no branch to restore it to after the release. An earlier release left it \
|
|
82 |
+ |
that way; any commits made since are on no branch and may exist nowhere \
|
|
83 |
+ |
else. Reattach it (`git checkout <branch>`, fast-forwarding if the commits \
|
|
84 |
+ |
should be kept) before releasing."
|
|
85 |
+ |
);
|
|
86 |
+ |
branches.push((host.clone(), branch));
|
|
87 |
+ |
|
| 64 |
88 |
|
// Advisory: `fetch --all` is non-zero if any one of a repo's remotes is
|
| 65 |
89 |
|
// unreachable, and blaming the tag for a dead mirror is what made this
|
| 66 |
90 |
|
// preflight misreport. Only the checkout below is allowed to fail.
|
| 115 |
139 |
|
mismatch.join(", "),
|
| 116 |
140 |
|
);
|
| 117 |
141 |
|
}
|
| 118 |
|
- |
Ok(())
|
|
142 |
+ |
Ok(branches)
|
|
143 |
+ |
}
|
|
144 |
+ |
|
|
145 |
+ |
/// Put every host's checkout back on the branch [`pin_release`] found it on.
|
|
146 |
+ |
///
|
|
147 |
+ |
/// Best-effort and non-fatal: the release itself is already decided by the time
|
|
148 |
+ |
/// this runs, and failing a green build because a `git checkout` did not take
|
|
149 |
+ |
/// would be worse than the detached tree it is cleaning up. A failure is logged
|
|
150 |
+ |
/// loudly instead, because the state it leaves behind is the silent one.
|
|
151 |
+ |
async fn restore_branches(state: &AppState, app: &AppId, branches: &[(String, String)]) {
|
|
152 |
+ |
let Some(cfg) = state.topo.app(app) else {
|
|
153 |
+ |
return;
|
|
154 |
+ |
};
|
|
155 |
+ |
for (host, branch) in branches {
|
|
156 |
+ |
let Some(exec) = state.executors.get(host) else {
|
|
157 |
+ |
continue;
|
|
158 |
+ |
};
|
|
159 |
+ |
// Serialize against a target still holding this host for its recipe run,
|
|
160 |
+ |
// so the restore can't move the tree mid-build.
|
|
161 |
+ |
let _host_guard = match state.host_locks.get(host).cloned() {
|
|
162 |
+ |
Some(lock) => Some(lock.lock_owned().await),
|
|
163 |
+ |
None => None,
|
|
164 |
+ |
};
|
|
165 |
+ |
let mut sink = DiscardSink;
|
|
166 |
+ |
let step = OpStep::shell(
|
|
167 |
+ |
Action::Build,
|
|
168 |
+ |
engine::git_restore_branch_cmd(&cfg.repo, branch),
|
|
169 |
+ |
);
|
|
170 |
+ |
match exec.run_streaming(&step, &mut sink).await {
|
|
171 |
+ |
Ok(out) if out.status.success() => {
|
|
172 |
+ |
tracing::debug!(%host, %branch, "restored checkout to its branch");
|
|
173 |
+ |
}
|
|
174 |
+ |
Ok(_) => tracing::error!(
|
|
175 |
+ |
%host, %branch, repo = %cfg.repo,
|
|
176 |
+ |
"could not restore the checkout to its branch; it is left DETACHED at the \
|
|
177 |
+ |
release tag, and commits made there will belong to no branch"
|
|
178 |
+ |
),
|
|
179 |
+ |
Err(e) => tracing::error!(
|
|
180 |
+ |
%host, %branch, repo = %cfg.repo, error = %e,
|
|
181 |
+ |
"could not restore the checkout to its branch; it is left DETACHED at the \
|
|
182 |
+ |
release tag, and commits made there will belong to no branch"
|
|
183 |
+ |
),
|
|
184 |
+ |
}
|
|
185 |
+ |
}
|
| 119 |
186 |
|
}
|
| 120 |
187 |
|
|
| 121 |
188 |
|
/// First 12 chars of a sha for a readable error.
|
| 191 |
258 |
|
|
| 192 |
259 |
|
// Pin every build host to the release tag and verify they agree, before any
|
| 193 |
260 |
|
// target task spawns. Off in tests (their repos aren't git checkouts).
|
| 194 |
|
- |
if state.cfg.pin_release_sha {
|
|
261 |
+ |
let pinned_branches = if state.cfg.pin_release_sha {
|
| 195 |
262 |
|
pin_release(&state, &app, &version, &targets)
|
| 196 |
263 |
|
.await
|
| 197 |
|
- |
.context("release preflight")?;
|
| 198 |
|
- |
}
|
|
264 |
+ |
.context("release preflight")?
|
|
265 |
+ |
} else {
|
|
266 |
+ |
Vec::new()
|
|
267 |
+ |
};
|
| 199 |
268 |
|
|
| 200 |
269 |
|
let build_id: i64 = sqlx::query_scalar(
|
| 201 |
270 |
|
"INSERT INTO builds (app, version, status, created_at) VALUES (?, ?, 'running', ?) RETURNING id",
|
| 266 |
335 |
|
crate::metrics::set_in_flight(active.len());
|
| 267 |
336 |
|
}
|
| 268 |
337 |
|
|
| 269 |
|
- |
// Mark the build done once all target tasks settle. Spawned so /build
|
| 270 |
|
- |
// returns immediately.
|
| 271 |
|
- |
tokio::spawn(finalize_build(state, build_id, set));
|
|
338 |
+ |
// Mark the build done once all target tasks settle, and put the pinned
|
|
339 |
+ |
// checkouts back on their branches. Spawned so /build returns immediately.
|
|
340 |
+ |
tokio::spawn(finalize_build(state, build_id, set, app, pinned_branches));
|
| 272 |
341 |
|
Ok(build_id)
|
| 273 |
342 |
|
}
|
| 274 |
343 |
|
|
| 557 |
626 |
|
/// the async wrappers. The old code aborted only the wrappers, which could not
|
| 558 |
627 |
|
/// reach the blocking bodies, so a build was marked failed while codesign kept
|
| 559 |
628 |
|
/// running on the mac.
|
| 560 |
|
- |
async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::JoinSet<()>) {
|
|
629 |
+ |
async fn finalize_build(
|
|
630 |
+ |
state: AppState,
|
|
631 |
+ |
build_id: i64,
|
|
632 |
+ |
mut set: tokio::task::JoinSet<()>,
|
|
633 |
+ |
app: AppId,
|
|
634 |
+ |
pinned_branches: Vec<(String, String)>,
|
|
635 |
+ |
) {
|
| 561 |
636 |
|
const MAX_WAIT: std::time::Duration = std::time::Duration::from_hours(6);
|
| 562 |
637 |
|
let deadline = tokio::time::Instant::now() + MAX_WAIT;
|
| 563 |
638 |
|
loop {
|
| 594 |
669 |
|
|
| 595 |
670 |
|
// Reap this build's latest-wins slots (only our own — a superseding build
|
| 596 |
671 |
|
// owns a different build_id and is left intact).
|
| 597 |
|
- |
{
|
|
672 |
+ |
let app_still_building = {
|
| 598 |
673 |
|
let mut active = state.active.lock().await;
|
| 599 |
674 |
|
active.retain(|_, slot| slot.build_id != build_id);
|
| 600 |
675 |
|
crate::metrics::set_in_flight(active.len());
|
|
676 |
+ |
active.keys().any(|(a, _)| a == &app)
|
|
677 |
+ |
};
|
|
678 |
+ |
|
|
679 |
+ |
// Undo the preflight's pin. Skipped if a superseding build for this same app
|
|
680 |
+ |
// still holds the checkout — it pinned the tree to ITS tag, and restoring the
|
|
681 |
+ |
// branch here would move that build off the commit it is releasing.
|
|
682 |
+ |
if !pinned_branches.is_empty() {
|
|
683 |
+ |
if app_still_building {
|
|
684 |
+ |
tracing::debug!(
|
|
685 |
+ |
build_id, %app,
|
|
686 |
+ |
"leaving the checkout pinned; a superseding build for this app is still running"
|
|
687 |
+ |
);
|
|
688 |
+ |
} else {
|
|
689 |
+ |
restore_branches(&state, &app, &pinned_branches).await;
|
|
690 |
+ |
}
|
| 601 |
691 |
|
}
|
| 602 |
692 |
|
|
| 603 |
693 |
|
let now = chrono::Utc::now().to_rfc3339();
|
| 1993 |
2083 |
|
assert_eq!(builds, 0, "a refused preflight writes no build row");
|
| 1994 |
2084 |
|
}
|
| 1995 |
2085 |
|
|
|
2086 |
+ |
/// The branch a release was launched from is still checked out afterwards.
|
|
2087 |
+ |
///
|
|
2088 |
+ |
/// The preflight pins every host to `v<version>`, which detaches HEAD. That
|
|
2089 |
+ |
/// is correct during the build and wrong to leave behind: git does not warn,
|
|
2090 |
+ |
/// and later commits succeed while belonging to no branch. makeover shipped
|
|
2091 |
+ |
/// 2.3.0 from exactly that state on 2026-07-28 — the published commit existed
|
|
2092 |
+ |
/// only as a detached HEAD on one machine, on no branch and no remote.
|
|
2093 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2094 |
+ |
async fn a_release_leaves_the_checkout_on_its_branch() {
|
|
2095 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
2096 |
+ |
let repo = tmp.path().join("demo");
|
|
2097 |
+ |
init_git_app(&repo, "0.0.1", Some("v0.0.1"));
|
|
2098 |
+ |
let branch_before = current_branch(&repo);
|
|
2099 |
+ |
assert!(!branch_before.is_empty(), "test repo starts on a branch");
|
|
2100 |
+ |
|
|
2101 |
+ |
let mut cfg = Config::for_tests(tmp.path());
|
|
2102 |
+ |
cfg.pin_release_sha = true;
|
|
2103 |
+ |
let pool = crate::db::open(&cfg.db_path).await.unwrap();
|
|
2104 |
+ |
let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
|
|
2105 |
+ |
let build_id = start_build(
|
|
2106 |
+ |
state,
|
|
2107 |
+ |
AppId::new("demo"),
|
|
2108 |
+ |
Version::parse("0.0.1").unwrap(),
|
|
2109 |
+ |
vec!["linux/x86_64".parse().unwrap()],
|
|
2110 |
+ |
)
|
|
2111 |
+ |
.await
|
|
2112 |
+ |
.unwrap();
|
|
2113 |
+ |
let (status, error) = await_target(&pool, build_id).await;
|
|
2114 |
+ |
assert_eq!(status, "ok", "the build itself should pass ({error})");
|
|
2115 |
+ |
|
|
2116 |
+ |
// finalize_build restores after the targets settle, so give it a moment.
|
|
2117 |
+ |
for _ in 0..50 {
|
|
2118 |
+ |
if current_branch(&repo) == branch_before {
|
|
2119 |
+ |
break;
|
|
2120 |
+ |
}
|
|
2121 |
+ |
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
2122 |
+ |
}
|
|
2123 |
+ |
assert_eq!(
|
|
2124 |
+ |
current_branch(&repo),
|
|
2125 |
+ |
branch_before,
|
|
2126 |
+ |
"the release must put the checkout back on its branch, not leave it detached"
|
|
2127 |
+ |
);
|
|
2128 |
+ |
}
|
|
2129 |
+ |
|
|
2130 |
+ |
/// A checkout that is ALREADY detached is refused, rather than released from
|
|
2131 |
+ |
/// and left detached again. It means an earlier release never cleaned up, and
|
|
2132 |
+ |
/// anything committed since is on no branch — which is precisely the state
|
|
2133 |
+ |
/// that has to be looked at by a human before more releases pile onto it.
|
|
2134 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2135 |
+ |
async fn a_detached_checkout_is_refused_before_it_is_pinned_again() {
|
|
2136 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
2137 |
+ |
let repo = tmp.path().join("demo");
|
|
2138 |
+ |
init_git_app(&repo, "0.0.1", Some("v0.0.1"));
|
|
2139 |
+ |
// Detach, standing in for a checkout an earlier release left on its tag.
|
|
2140 |
+ |
let out = std::process::Command::new("git")
|
|
2141 |
+ |
.args(["checkout", "--detach", "-q", "HEAD"])
|
|
2142 |
+ |
.current_dir(&repo)
|
|
2143 |
+ |
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
2144 |
+ |
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
2145 |
+ |
.output()
|
|
2146 |
+ |
.expect("git runs");
|
|
2147 |
+ |
assert!(out.status.success());
|
|
2148 |
+ |
assert!(current_branch(&repo).is_empty(), "repo is detached");
|
|
2149 |
+ |
|
|
2150 |
+ |
let mut cfg = Config::for_tests(tmp.path());
|
|
2151 |
+ |
cfg.pin_release_sha = true;
|
|
2152 |
+ |
let pool = crate::db::open(&cfg.db_path).await.unwrap();
|
|
2153 |
+ |
let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
|
|
2154 |
+ |
let err = start_build(
|
|
2155 |
+ |
state,
|
|
2156 |
+ |
AppId::new("demo"),
|
|
2157 |
+ |
Version::parse("0.0.1").unwrap(),
|
|
2158 |
+ |
vec!["linux/x86_64".parse().unwrap()],
|
|
2159 |
+ |
)
|
|
2160 |
+ |
.await
|
|
2161 |
+ |
.unwrap_err();
|
|
2162 |
+ |
let msg = format!("{err:#}");
|
|
2163 |
+ |
assert!(
|
|
2164 |
+ |
msg.contains("detached HEAD"),
|
|
2165 |
+ |
"the refusal should name the detached checkout, got: {msg}"
|
|
2166 |
+ |
);
|
|
2167 |
+ |
let builds: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds")
|
|
2168 |
+ |
.fetch_one(&pool)
|
|
2169 |
+ |
.await
|
|
2170 |
+ |
.unwrap();
|
|
2171 |
+ |
assert_eq!(builds, 0, "a refused preflight writes no build row");
|
|
2172 |
+ |
}
|
|
2173 |
+ |
|
|
2174 |
+ |
/// The branch `repo` is on, empty on a detached HEAD.
|
|
2175 |
+ |
fn current_branch(repo: &std::path::Path) -> String {
|
|
2176 |
+ |
let out = std::process::Command::new("git")
|
|
2177 |
+ |
.args(["symbolic-ref", "-q", "--short", "HEAD"])
|
|
2178 |
+ |
.current_dir(repo)
|
|
2179 |
+ |
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
2180 |
+ |
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
2181 |
+ |
.output()
|
|
2182 |
+ |
.expect("git runs");
|
|
2183 |
+ |
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
2184 |
+ |
}
|
|
2185 |
+ |
|
| 1996 |
2186 |
|
/// An unreachable remote does not fail a release whose tag is present.
|
| 1997 |
2187 |
|
///
|
| 1998 |
2188 |
|
/// The preflight used to run `fetch --all --tags --prune && checkout`, and
|