| 11 |
11 |
|
use crate::events::{self, Event};
|
| 12 |
12 |
|
use crate::state::AppState;
|
| 13 |
13 |
|
use anyhow::{Context, Result};
|
|
14 |
+ |
use ops_exec::{Action, LogSink, Step as OpStep};
|
| 14 |
15 |
|
use std::path::PathBuf;
|
| 15 |
16 |
|
use std::sync::Arc;
|
| 16 |
17 |
|
|
|
18 |
+ |
/// A [`LogSink`] that drops what it's handed — the release preflight runs git on
|
|
19 |
+ |
/// each host for its exit code and (via a separate `rev-parse`) the sha in
|
|
20 |
+ |
/// `RunOutput`, not for a streamed log, so there is no step to stream into.
|
|
21 |
+ |
struct DiscardSink;
|
|
22 |
+ |
|
|
23 |
+ |
#[async_trait::async_trait]
|
|
24 |
+ |
impl LogSink for DiscardSink {
|
|
25 |
+ |
async fn write_chunk(&mut self, _bytes: &[u8]) {}
|
|
26 |
+ |
}
|
|
27 |
+ |
|
|
28 |
+ |
/// Preflight barrier: pin every host that will build a target for this release
|
|
29 |
+ |
/// to the tag `v<version>` and refuse the build unless they all report the SAME
|
|
30 |
+ |
/// commit. Recipes used to `git pull --ff-only` per host, so `mbp`/`astra`/`fw13`
|
|
31 |
+ |
/// each built whatever `main` was at pull time and the artifacts were filed
|
|
32 |
+ |
/// under the daemon host's version. This runs before any target task spawns, so
|
|
33 |
+ |
/// a mixed-source release is stopped before a single artifact is built.
|
|
34 |
+ |
async fn pin_release(
|
|
35 |
+ |
state: &AppState,
|
|
36 |
+ |
app: &AppId,
|
|
37 |
+ |
version: &Version,
|
|
38 |
+ |
targets: &[Target],
|
|
39 |
+ |
) -> Result<()> {
|
|
40 |
+ |
let cfg = state
|
|
41 |
+ |
.topo
|
|
42 |
+ |
.app(app)
|
|
43 |
+ |
.ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
|
|
44 |
+ |
let repo = cfg.repo.clone();
|
|
45 |
+ |
|
|
46 |
+ |
// The distinct hosts across the requested targets (order-stable).
|
|
47 |
+ |
let mut hosts: Vec<String> = Vec::new();
|
|
48 |
+ |
for t in targets {
|
|
49 |
+ |
if let Some(h) = state.topo.host_for(*t)
|
|
50 |
+ |
&& !hosts.contains(&h.name)
|
|
51 |
+ |
{
|
|
52 |
+ |
hosts.push(h.name.clone());
|
|
53 |
+ |
}
|
|
54 |
+ |
}
|
|
55 |
+ |
|
|
56 |
+ |
let mut shas: Vec<(String, String)> = Vec::new();
|
|
57 |
+ |
for host in &hosts {
|
|
58 |
+ |
let exec = state
|
|
59 |
+ |
.executors
|
|
60 |
+ |
.get(host)
|
|
61 |
+ |
.ok_or_else(|| anyhow::anyhow!("no executor for host `{host}`"))?;
|
|
62 |
+ |
let mut sink = DiscardSink;
|
|
63 |
+ |
|
|
64 |
+ |
let checkout = OpStep::shell(
|
|
65 |
+ |
Action::Build,
|
|
66 |
+ |
engine::git_fetch_checkout_cmd(&repo, version),
|
|
67 |
+ |
);
|
|
68 |
+ |
let out = exec
|
|
69 |
+ |
.run_streaming(&checkout, &mut sink)
|
|
70 |
+ |
.await
|
|
71 |
+ |
.with_context(|| format!("release preflight: checkout on `{host}`"))?;
|
|
72 |
+ |
anyhow::ensure!(
|
|
73 |
+ |
out.status.success(),
|
|
74 |
+ |
"release preflight: `git checkout v{version}` failed on `{host}` \
|
|
75 |
+ |
(is the tag pushed to a remote every host can fetch?)"
|
|
76 |
+ |
);
|
|
77 |
+ |
|
|
78 |
+ |
let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(&repo));
|
|
79 |
+ |
let out = exec
|
|
80 |
+ |
.run_streaming(&rev, &mut sink)
|
|
81 |
+ |
.await
|
|
82 |
+ |
.with_context(|| format!("release preflight: rev-parse on `{host}`"))?;
|
|
83 |
+ |
anyhow::ensure!(
|
|
84 |
+ |
out.status.success(),
|
|
85 |
+ |
"release preflight: `git rev-parse HEAD` failed on `{host}`"
|
|
86 |
+ |
);
|
|
87 |
+ |
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
88 |
+ |
shas.push((host.clone(), sha));
|
|
89 |
+ |
}
|
|
90 |
+ |
|
|
91 |
+ |
// The barrier: every host must be on the same commit.
|
|
92 |
+ |
if let Some((first_host, first_sha)) = shas.first() {
|
|
93 |
+ |
let mismatch: Vec<String> = shas
|
|
94 |
+ |
.iter()
|
|
95 |
+ |
.filter(|(_, sha)| sha != first_sha)
|
|
96 |
+ |
.map(|(h, sha)| format!("{h}={}", short(sha)))
|
|
97 |
+ |
.collect();
|
|
98 |
+ |
anyhow::ensure!(
|
|
99 |
+ |
mismatch.is_empty(),
|
|
100 |
+ |
"release preflight: build hosts are on different commits for v{version} \
|
|
101 |
+ |
({}={}, {}); refusing to build a release from mixed sources",
|
|
102 |
+ |
first_host,
|
|
103 |
+ |
short(first_sha),
|
|
104 |
+ |
mismatch.join(", "),
|
|
105 |
+ |
);
|
|
106 |
+ |
}
|
|
107 |
+ |
Ok(())
|
|
108 |
+ |
}
|
|
109 |
+ |
|
|
110 |
+ |
/// First 12 chars of a sha for a readable error.
|
|
111 |
+ |
fn short(sha: &str) -> &str {
|
|
112 |
+ |
sha.get(..12).unwrap_or(sha)
|
|
113 |
+ |
}
|
|
114 |
+ |
|
| 17 |
115 |
|
/// Resolve the version to build: explicit, or read from `tauri.conf.json`.
|
| 18 |
116 |
|
///
|
| 19 |
117 |
|
/// Returns typed errors so the route maps user mistakes (unknown app, bad
|
| 80 |
178 |
|
.context("version preflight")?;
|
| 81 |
179 |
|
}
|
| 82 |
180 |
|
|
|
181 |
+ |
// Pin every build host to the release tag and verify they agree, before any
|
|
182 |
+ |
// target task spawns. Off in tests (their repos aren't git checkouts).
|
|
183 |
+ |
if state.cfg.pin_release_sha {
|
|
184 |
+ |
pin_release(&state, &app, &version, &targets)
|
|
185 |
+ |
.await
|
|
186 |
+ |
.context("release preflight")?;
|
|
187 |
+ |
}
|
|
188 |
+ |
|
| 83 |
189 |
|
let build_id: i64 = sqlx::query_scalar(
|
| 84 |
190 |
|
"INSERT INTO builds (app, version, status, created_at) VALUES (?, ?, 'running', ?) RETURNING id",
|
| 85 |
191 |
|
)
|
| 1523 |
1629 |
|
assert_eq!(count, 1, "linux published once the gate was satisfied");
|
| 1524 |
1630 |
|
}
|
| 1525 |
1631 |
|
|
|
1632 |
+ |
/// A committed + tagged git repo whose linux recipe pins the host to the tag
|
|
1633 |
+ |
/// via `checkout_sha`. `tag` is created only when `Some`.
|
|
1634 |
+ |
fn init_git_app(repo: &std::path::Path, tauri_version: &str, tag: Option<&str>) {
|
|
1635 |
+ |
std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
|
|
1636 |
+ |
std::fs::write(
|
|
1637 |
+ |
repo.join("src-tauri/tauri.conf.json"),
|
|
1638 |
+ |
format!("{{\"version\":\"{tauri_version}\"}}"),
|
|
1639 |
+ |
)
|
|
1640 |
+ |
.unwrap();
|
|
1641 |
+ |
std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
|
|
1642 |
+ |
std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
|
|
1643 |
+ |
std::fs::write(
|
|
1644 |
+ |
repo.join("dist/recipes/linux.rhai"),
|
|
1645 |
+ |
"step(\"checkout\");\nlet s = checkout_sha(build_host());\nlog(\"pinned \" + s);\n\
|
|
1646 |
+ |
step(\"build\");\nsh_ok(build_host(), \"true\");\n",
|
|
1647 |
+ |
)
|
|
1648 |
+ |
.unwrap();
|
|
1649 |
+ |
// Isolate from the dev's global git config (which forces signed tags).
|
|
1650 |
+ |
let run = |args: &[&str]| {
|
|
1651 |
+ |
let out = std::process::Command::new("git")
|
|
1652 |
+ |
.args(args)
|
|
1653 |
+ |
.current_dir(repo)
|
|
1654 |
+ |
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
1655 |
+ |
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
1656 |
+ |
.output()
|
|
1657 |
+ |
.expect("git runs");
|
|
1658 |
+ |
assert!(
|
|
1659 |
+ |
out.status.success(),
|
|
1660 |
+ |
"git {args:?}: {}",
|
|
1661 |
+ |
String::from_utf8_lossy(&out.stderr)
|
|
1662 |
+ |
);
|
|
1663 |
+ |
};
|
|
1664 |
+ |
run(&["init", "-q"]);
|
|
1665 |
+ |
run(&["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"]);
|
|
1666 |
+ |
run(&[
|
|
1667 |
+ |
"-c",
|
|
1668 |
+ |
"user.email=t@t",
|
|
1669 |
+ |
"-c",
|
|
1670 |
+ |
"user.name=t",
|
|
1671 |
+ |
"commit",
|
|
1672 |
+ |
"-q",
|
|
1673 |
+ |
"-m",
|
|
1674 |
+ |
"init",
|
|
1675 |
+ |
]);
|
|
1676 |
+ |
if let Some(t) = tag {
|
|
1677 |
+ |
run(&["tag", t]);
|
|
1678 |
+ |
}
|
|
1679 |
+ |
}
|
|
1680 |
+ |
|
|
1681 |
+ |
fn one_host_topo(repo: &std::path::Path) -> Topology {
|
|
1682 |
+ |
Topology::from_str_for_tests(&format!(
|
|
1683 |
+ |
"[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
|
|
1684 |
+ |
[app.demo]\nrepo = \"{}\"\n",
|
|
1685 |
+ |
repo.display()
|
|
1686 |
+ |
))
|
|
1687 |
+ |
.unwrap()
|
|
1688 |
+ |
}
|
|
1689 |
+ |
|
|
1690 |
+ |
/// The release preflight (pin on) pins the host to the tag and the build
|
|
1691 |
+ |
/// proceeds. Exercises both the barrier and the `checkout_sha` host function.
|
|
1692 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
1693 |
+ |
async fn release_preflight_pins_and_builds_when_the_tag_is_present() {
|
|
1694 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
1695 |
+ |
let repo = tmp.path().join("demo");
|
|
1696 |
+ |
init_git_app(&repo, "0.0.1", Some("v0.0.1"));
|
|
1697 |
+ |
let mut cfg = Config::for_tests(tmp.path());
|
|
1698 |
+ |
cfg.pin_release_sha = true;
|
|
1699 |
+ |
let pool = crate::db::open(&cfg.db_path).await.unwrap();
|
|
1700 |
+ |
let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
|
|
1701 |
+ |
let build_id = start_build(
|
|
1702 |
+ |
state,
|
|
1703 |
+ |
AppId::new("demo"),
|
|
1704 |
+ |
Version::parse("0.0.1").unwrap(),
|
|
1705 |
+ |
vec!["linux/x86_64".parse().unwrap()],
|
|
1706 |
+ |
)
|
|
1707 |
+ |
.await
|
|
1708 |
+ |
.unwrap();
|
|
1709 |
+ |
let (status, error) = await_target(&pool, build_id).await;
|
|
1710 |
+ |
assert_eq!(status, "ok", "a pinned build should succeed ({error})");
|
|
1711 |
+ |
}
|
|
1712 |
+ |
|
|
1713 |
+ |
/// The preflight refuses the build (before any row is written) when the
|
|
1714 |
+ |
/// release tag does not exist on the host — a missing/unpushed tag can't
|
|
1715 |
+ |
/// silently fall back to whatever `main` is.
|
|
1716 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
1717 |
+ |
async fn release_preflight_refuses_when_the_release_tag_is_missing() {
|
|
1718 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
1719 |
+ |
let repo = tmp.path().join("demo");
|
|
1720 |
+ |
// App is 0.0.2 (so the version check passes) but only v0.0.1 is tagged.
|
|
1721 |
+ |
init_git_app(&repo, "0.0.2", Some("v0.0.1"));
|
|
1722 |
+ |
let mut cfg = Config::for_tests(tmp.path());
|
|
1723 |
+ |
cfg.pin_release_sha = true;
|
|
1724 |
+ |
let pool = crate::db::open(&cfg.db_path).await.unwrap();
|
|
1725 |
+ |
let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
|
|
1726 |
+ |
let err = start_build(
|
|
1727 |
+ |
state,
|
|
1728 |
+ |
AppId::new("demo"),
|
|
1729 |
+ |
Version::parse("0.0.2").unwrap(),
|
|
1730 |
+ |
vec!["linux/x86_64".parse().unwrap()],
|
|
1731 |
+ |
)
|
|
1732 |
+ |
.await
|
|
1733 |
+ |
.unwrap_err();
|
|
1734 |
+ |
let msg = format!("{err:#}");
|
|
1735 |
+ |
assert!(
|
|
1736 |
+ |
msg.contains("release preflight") && msg.contains("v0.0.2"),
|
|
1737 |
+ |
"expected a preflight tag error, got: {msg}"
|
|
1738 |
+ |
);
|
|
1739 |
+ |
// Refused before anything was recorded.
|
|
1740 |
+ |
let builds: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds")
|
|
1741 |
+ |
.fetch_one(&pool)
|
|
1742 |
+ |
.await
|
|
1743 |
+ |
.unwrap();
|
|
1744 |
+ |
assert_eq!(builds, 0, "a refused preflight writes no build row");
|
|
1745 |
+ |
}
|
|
1746 |
+ |
|
| 1526 |
1747 |
|
/// The audit fix: a `build` step dispatched to a host whose executor lacks the
|
| 1527 |
1748 |
|
/// `build` capability is denied at the transport BEFORE the command runs. This
|
| 1528 |
1749 |
|
/// is the structural guarantee behind "never build on prod" — a recipe naming
|