| 41 |
41 |
|
pub dmg_name: String,
|
| 42 |
42 |
|
}
|
| 43 |
43 |
|
|
|
44 |
+ |
/// Reject a `~` in a path meant for the build host.
|
|
45 |
+ |
///
|
|
46 |
+ |
/// These paths are consumed remotely (as a step `cwd`, or interpolated into a
|
|
47 |
+ |
/// command that gets sh-quoted), so nothing ever expands the tilde: the caller
|
|
48 |
+ |
/// cannot expand it either, because the build host's home is not this host's
|
|
49 |
+ |
/// (`/Users/max` vs `/home/max`). An unexpanded `~` used to surface as
|
|
50 |
+ |
/// `cd: ~/Code/Apps/goingson: No such file or directory` after the agent had
|
|
51 |
+ |
/// already been contacted. Fail early and say what to write instead.
|
|
52 |
+ |
fn reject_tilde(field: &str, value: &str) -> Result<()> {
|
|
53 |
+ |
anyhow::ensure!(
|
|
54 |
+ |
!value.starts_with('~'),
|
|
55 |
+ |
"{field} = {value:?} uses `~`, which is never expanded: this path is used on \
|
|
56 |
+ |
the build host, and its home directory is not this machine's. Write it \
|
|
57 |
+ |
absolute, e.g. /Users/<user>/{}",
|
|
58 |
+ |
value.trim_start_matches("~/")
|
|
59 |
+ |
);
|
|
60 |
+ |
Ok(())
|
|
61 |
+ |
}
|
|
62 |
+ |
|
| 44 |
63 |
|
impl ReleasePlan {
|
|
64 |
+ |
/// Validate the plan's build-host paths before any step runs.
|
|
65 |
+ |
pub fn validate(&self) -> Result<()> {
|
|
66 |
+ |
reject_tilde("repo_path", &self.repo_path)?;
|
|
67 |
+ |
reject_tilde("env_file", &self.env_file)?;
|
|
68 |
+ |
reject_tilde("dist_root", &self.dist_root)?;
|
|
69 |
+ |
Ok(())
|
|
70 |
+ |
}
|
|
71 |
+ |
|
| 45 |
72 |
|
/// Tauri's default macOS DMG name for an aarch64 build.
|
| 46 |
73 |
|
pub fn default_dmg_name(app_product_name: &str, version: &str) -> String {
|
| 47 |
74 |
|
format!("{app_product_name}_{version}_aarch64.dmg")
|
| 48 |
75 |
|
}
|
| 49 |
76 |
|
|
| 50 |
|
- |
/// `git pull` + checkout the release tag on the build host. Gated as
|
| 51 |
|
- |
/// `build` (source prep is part of building).
|
|
77 |
+ |
/// Fetch + checkout the release tag on the build host. Gated as `build`
|
|
78 |
+ |
/// (source prep is part of building).
|
|
79 |
+ |
///
|
|
80 |
+ |
/// Fetches rather than pulls. All this step needs is the tag, and `git pull`
|
|
81 |
+ |
/// additionally requires the host's branch to have an upstream configured --
|
|
82 |
+ |
/// which is per-host state a release driver should not depend on. When mbp's
|
|
83 |
+ |
/// `main` had no tracking branch, a bare `git pull --ff-only` failed with
|
|
84 |
+ |
/// git's "no tracking information" advice and nothing pointing at the host.
|
|
85 |
+ |
/// `git fetch --all --tags` needs no tracking config and gets the tag from
|
|
86 |
+ |
/// whichever remote has it.
|
| 52 |
87 |
|
pub fn checkout_step(&self) -> Step {
|
| 53 |
88 |
|
Step::shell(
|
| 54 |
89 |
|
Action::Build,
|
| 55 |
|
- |
format!("set -e; git pull --ff-only && git checkout v{}", self.version),
|
|
90 |
+ |
format!("set -e; git fetch --all --tags --prune && git checkout v{}", self.version),
|
| 56 |
91 |
|
)
|
| 57 |
92 |
|
.with_cwd(&self.repo_path)
|
| 58 |
93 |
|
}
|
| 116 |
151 |
|
out.status.success(),
|
| 117 |
152 |
|
"{label} failed (exit {}): {}",
|
| 118 |
153 |
|
out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()),
|
| 119 |
|
- |
String::from_utf8_lossy(&out.stderr),
|
|
154 |
+ |
failure_tail(&out),
|
| 120 |
155 |
|
);
|
| 121 |
156 |
|
Ok(out)
|
| 122 |
157 |
|
}
|
| 123 |
158 |
|
|
|
159 |
+ |
/// The last few lines of a failed step, for the error message.
|
|
160 |
+ |
///
|
|
161 |
+ |
/// Reads stderr *and* stdout: AgentRpc streams merged output as chunks and
|
|
162 |
+ |
/// leaves `stderr` empty, so keying only on stderr produced a bare
|
|
163 |
+ |
/// `checkout failed (exit 1):` with nothing after the colon while the real git
|
|
164 |
+ |
/// error scrolled past in the streamed log.
|
|
165 |
+ |
fn failure_tail(out: &RunOutput) -> String {
|
|
166 |
+ |
const MAX_LINES: usize = 5;
|
|
167 |
+ |
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
168 |
+ |
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
169 |
+ |
let source = if stderr.trim().is_empty() { stdout } else { stderr };
|
|
170 |
+ |
let tail: Vec<&str> = source
|
|
171 |
+ |
.lines()
|
|
172 |
+ |
.filter(|l| !l.trim().is_empty())
|
|
173 |
+ |
.rev()
|
|
174 |
+ |
.take(MAX_LINES)
|
|
175 |
+ |
.collect();
|
|
176 |
+ |
if tail.is_empty() {
|
|
177 |
+ |
return "(no output captured; see the streamed log above)".into();
|
|
178 |
+ |
}
|
|
179 |
+ |
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
|
|
180 |
+ |
}
|
|
181 |
+ |
|
| 124 |
182 |
|
/// Drive the full macOS release recipe through `exec`, streaming all output to
|
| 125 |
183 |
|
/// `sink`. Does NOT pull the artifact — the caller does that (transport choice
|
| 126 |
184 |
|
/// belongs there: `AgentRpc::pull` for a single DMG, or `SshExec::pull` for a
|
| 185 |
243 |
|
}
|
| 186 |
244 |
|
|
| 187 |
245 |
|
#[test]
|
| 188 |
|
- |
fn checkout_step_pulls_and_checks_out_the_tag() {
|
|
246 |
+ |
fn checkout_step_fetches_and_checks_out_the_tag() {
|
| 189 |
247 |
|
let s = go_plan("/repo", "/dist").checkout_step();
|
| 190 |
248 |
|
assert_eq!(s.action, Action::Build);
|
| 191 |
249 |
|
let script = s.argv.last().unwrap();
|
| 193 |
251 |
|
assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo")));
|
| 194 |
252 |
|
}
|
| 195 |
253 |
|
|
|
254 |
+ |
/// `git pull` needs the build host's branch to have an upstream; a release
|
|
255 |
+ |
/// driver must not depend on that per-host state. Only the tag is needed.
|
|
256 |
+ |
#[test]
|
|
257 |
+ |
fn checkout_step_does_not_depend_on_a_tracking_branch() {
|
|
258 |
+ |
let script = go_plan("/repo", "/dist").checkout_step().argv.last().unwrap().clone();
|
|
259 |
+ |
assert!(script.contains("git fetch"), "{script}");
|
|
260 |
+ |
assert!(!script.contains("git pull"), "must not use pull: {script}");
|
|
261 |
+ |
}
|
|
262 |
+ |
|
|
263 |
+ |
#[test]
|
|
264 |
+ |
fn validate_rejects_tilde_in_build_host_paths() {
|
|
265 |
+ |
// Each build-host path is checked; the local dest_dir is not a plan field.
|
|
266 |
+ |
for plan in [
|
|
267 |
+ |
go_plan("~/Code/Apps/goingson", "/dist"),
|
|
268 |
+ |
go_plan("/repo", "~/Dist/goingson/macos"),
|
|
269 |
+ |
] {
|
|
270 |
+ |
let err = plan.validate().unwrap_err().to_string();
|
|
271 |
+ |
assert!(err.contains('~'), "should name the offending value: {err}");
|
|
272 |
+ |
}
|
|
273 |
+ |
|
|
274 |
+ |
let mut env_tilde = go_plan("/repo", "/dist");
|
|
275 |
+ |
env_tilde.env_file = "~/.tauri/passwords.env".into();
|
|
276 |
+ |
assert!(env_tilde.validate().is_err());
|
|
277 |
+ |
}
|
|
278 |
+ |
|
|
279 |
+ |
#[test]
|
|
280 |
+ |
fn validate_accepts_absolute_build_host_paths() {
|
|
281 |
+ |
let mut plan = go_plan("/Users/max/Code/Apps/goingson", "/Users/max/Dist/goingson/macos");
|
|
282 |
+ |
plan.env_file = "/Users/max/.tauri/passwords.env".into();
|
|
283 |
+ |
assert!(plan.validate().is_ok());
|
|
284 |
+ |
}
|
|
285 |
+ |
|
|
286 |
+ |
/// AgentRpc streams merged output and leaves stderr empty, so a failure tail
|
|
287 |
+ |
/// keyed only on stderr renders as nothing at all.
|
|
288 |
+ |
#[test]
|
|
289 |
+ |
fn failure_tail_falls_back_to_stdout_when_stderr_is_empty() {
|
|
290 |
+ |
let out = RunOutput {
|
|
291 |
+ |
status: std::process::ExitStatus::default(),
|
|
292 |
+ |
stdout: b"fatal: not a git repository\n".to_vec(),
|
|
293 |
+ |
stderr: Vec::new(),
|
|
294 |
+ |
};
|
|
295 |
+ |
assert!(failure_tail(&out).contains("not a git repository"));
|
|
296 |
+ |
}
|
|
297 |
+ |
|
|
298 |
+ |
#[test]
|
|
299 |
+ |
fn failure_tail_reports_when_nothing_was_captured() {
|
|
300 |
+ |
let out = RunOutput {
|
|
301 |
+ |
status: std::process::ExitStatus::default(),
|
|
302 |
+ |
stdout: Vec::new(),
|
|
303 |
+ |
stderr: Vec::new(),
|
|
304 |
+ |
};
|
|
305 |
+ |
assert!(failure_tail(&out).contains("no output captured"));
|
|
306 |
+ |
}
|
|
307 |
+ |
|
| 196 |
308 |
|
#[test]
|
| 197 |
309 |
|
fn release_step_is_gated_as_sign_and_sources_secrets() {
|
| 198 |
310 |
|
let s = go_plan("/repo", "/dist").release_step();
|