Skip to content

Commit 94722f6

Browse files
authored
fix: guard stale and downgrade deployments (#8186)
1 parent 8234256 commit 94722f6

10 files changed

Lines changed: 444 additions & 11 deletions

File tree

src/commands/deploy.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ pub struct DeployArgs {
7272
/// Skip auto-pulling latest changes before deploy
7373
#[arg(long)]
7474
pub no_pull: bool,
75+
/// Deploy a local build even when its source checkout is behind its upstream
76+
#[arg(long)]
77+
pub allow_stale_source: bool,
78+
/// Deploy a local build even when its semantic version is older than the remote
79+
#[arg(long)]
80+
pub allow_downgrade: bool,
7581
/// Deploy from current branch HEAD instead of the latest tag
7682
#[arg(long)]
7783
pub head: bool,
@@ -359,6 +365,8 @@ fn build_config(args: &DeployArgs, skip_build: bool) -> DeployConfig {
359365
skip_deps_hydration: crate::commands::skip_deps_hydration(),
360366
expected_version: args.version.clone(),
361367
no_pull: args.no_pull,
368+
allow_stale_source: args.allow_stale_source,
369+
allow_downgrade: args.allow_downgrade,
362370
head: args.head,
363371
requested_ref: args.requested_ref.clone(),
364372
tagged: args.tagged,

src/core/deploy/execution/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ mod tests {
160160
skip_deps_hydration: false,
161161
expected_version: None,
162162
no_pull: false,
163+
allow_stale_source: false,
164+
allow_downgrade: false,
163165
head: false,
164166
requested_ref: None,
165167
tagged: false,
@@ -218,6 +220,8 @@ mod tests {
218220
skip_deps_hydration: false,
219221
expected_version: None,
220222
no_pull: false,
223+
allow_stale_source: false,
224+
allow_downgrade: false,
221225
head: true,
222226
requested_ref: None,
223227
tagged: false,
@@ -249,6 +253,8 @@ mod tests {
249253
skip_deps_hydration: false,
250254
expected_version: None,
251255
no_pull: false,
256+
allow_stale_source: false,
257+
allow_downgrade: false,
252258
head: false,
253259
requested_ref: None,
254260
tagged: true,
@@ -291,6 +297,8 @@ mod tests {
291297
skip_deps_hydration: false,
292298
expected_version: Some("1.2.3".to_string()),
293299
no_pull: false,
300+
allow_stale_source: false,
301+
allow_downgrade: false,
294302
head: false,
295303
requested_ref: None,
296304
tagged: false,
@@ -333,6 +341,8 @@ mod tests {
333341
skip_deps_hydration: false,
334342
expected_version: Some("1.2.3".to_string()),
335343
no_pull: false,
344+
allow_stale_source: false,
345+
allow_downgrade: false,
336346
head: false,
337347
requested_ref: None,
338348
tagged: false,
@@ -366,6 +376,8 @@ mod tests {
366376
skip_deps_hydration: false,
367377
expected_version: Some("1.2.3".to_string()),
368378
no_pull: false,
379+
allow_stale_source: false,
380+
allow_downgrade: false,
369381
head: false,
370382
requested_ref: None,
371383
tagged: false,
@@ -412,6 +424,8 @@ mod tests {
412424
skip_deps_hydration: false,
413425
expected_version: Some("1.2.3".to_string()),
414426
no_pull: false,
427+
allow_stale_source: false,
428+
allow_downgrade: false,
415429
head: false,
416430
requested_ref: None,
417431
tagged: false,
@@ -483,6 +497,8 @@ mod tests {
483497
skip_deps_hydration: false,
484498
expected_version: None,
485499
no_pull: false,
500+
allow_stale_source: false,
501+
allow_downgrade: false,
486502
head: true,
487503
requested_ref: None,
488504
tagged: false,

src/core/deploy/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ pub fn run_multi(
184184
skip_deps_hydration: config.skip_deps_hydration,
185185
expected_version: config.expected_version.clone(),
186186
no_pull: config.no_pull,
187+
allow_stale_source: config.allow_stale_source,
188+
allow_downgrade: config.allow_downgrade,
187189
head: config.head,
188190
requested_ref: config.requested_ref.clone(),
189191
tagged: config.tagged,
@@ -319,6 +321,8 @@ mod tests {
319321
skip_deps_hydration: false,
320322
expected_version: None,
321323
no_pull: false,
324+
allow_stale_source: false,
325+
allow_downgrade: false,
322326
head: false,
323327
requested_ref: None,
324328
tagged: false,

src/core/deploy/orchestration/mod.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ mod smoke_check;
2424

2525
use modes::{extension_skipped_results, run_check_mode, run_dry_run_mode};
2626
use preflight::{
27-
check_uncommitted_changes, check_unreleased_commits, sync_components, verify_expected_version,
28-
warn_non_default_branch,
27+
check_uncommitted_changes, check_unreleased_commits, guard_local_build_downgrades,
28+
guard_local_build_source_freshness, local_build_components, sync_components,
29+
verify_expected_version, warn_non_default_branch,
2930
};
3031
use smoke_check::run_post_deploy_smoke;
3132

@@ -169,13 +170,18 @@ pub(super) fn deploy_components(
169170
.iter()
170171
.filter_map(|c| version::get_component_version(c).map(|v| (c.id.clone(), v)))
171172
.collect();
172-
let remote_versions =
173-
if config.outdated || config.behind_upstream || config.dry_run || config.check {
174-
fetch_remote_versions_for_project(&components, Some(&project), base_path, &ctx.client)
175-
.versions
176-
} else {
177-
HashMap::new()
178-
};
173+
let local_build_components = local_build_components(&components, config);
174+
let remote_versions = if config.outdated
175+
|| config.behind_upstream
176+
|| config.dry_run
177+
|| config.check
178+
|| !local_build_components.is_empty()
179+
{
180+
fetch_remote_versions_for_project(&components, Some(&project), base_path, &ctx.client)
181+
.versions
182+
} else {
183+
HashMap::new()
184+
};
179185

180186
// Check and dry-run modes return early without building or deploying
181187
if config.check {
@@ -213,6 +219,8 @@ pub(super) fn deploy_components(
213219
sync_components(&local_build_components)?;
214220
}
215221

222+
guard_local_build_source_freshness(&local_build_components, config)?;
223+
216224
// Warn when --head deploys from a non-default branch (safety guardrail)
217225
if config.head && !config.skip_build {
218226
warn_non_default_branch(&local_build_components, config)?;
@@ -249,6 +257,13 @@ pub(super) fn deploy_components(
249257
.filter_map(|c| version::get_component_version(c).map(|v| (c.id.clone(), v)))
250258
.collect();
251259

260+
guard_local_build_downgrades(
261+
&local_build_components,
262+
&local_versions,
263+
&remote_versions,
264+
config,
265+
)?;
266+
252267
// Build and validate every local artifact before the first remote write.
253268
let prepared_deployments = match prepare_component_deployments(
254269
&components,
@@ -509,6 +524,8 @@ mod tests {
509524
skip_deps_hydration: false,
510525
expected_version: None,
511526
no_pull: false,
527+
allow_stale_source: false,
528+
allow_downgrade: false,
512529
head: false,
513530
requested_ref: None,
514531
tagged: false,

src/core/deploy/orchestration/modes.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ mod tests {
296296
skip_deps_hydration: false,
297297
expected_version: None,
298298
no_pull: false,
299+
allow_stale_source: false,
300+
allow_downgrade: false,
299301
head: false,
300302
requested_ref: Some("reviewed".to_string()),
301303
tagged: false,
@@ -335,6 +337,63 @@ mod tests {
335337
);
336338
}
337339

340+
#[test]
341+
fn check_and_dry_run_report_remote_newer_versions_without_safety_refusal() {
342+
let component = Component {
343+
id: "fixture".to_string(),
344+
local_path: "/not/a/checkout".to_string(),
345+
build_artifact: Some("build/fixture.zip".to_string()),
346+
..Component::default()
347+
};
348+
let local_versions = HashMap::from([("fixture".to_string(), "1.2.3".to_string())]);
349+
let remote_versions = HashMap::from([("fixture".to_string(), "1.3.0".to_string())]);
350+
let config = DeployConfig {
351+
component_ids: vec!["fixture".to_string()],
352+
all: false,
353+
outdated: false,
354+
behind_upstream: false,
355+
dry_run: true,
356+
check: false,
357+
force: false,
358+
skip_build: false,
359+
keep_deps: false,
360+
skip_deps_hydration: false,
361+
expected_version: None,
362+
no_pull: true,
363+
allow_stale_source: false,
364+
allow_downgrade: false,
365+
head: true,
366+
requested_ref: None,
367+
tagged: false,
368+
};
369+
370+
let checked = run_check_mode(
371+
std::slice::from_ref(&component),
372+
&local_versions,
373+
&remote_versions,
374+
&[],
375+
&Project::default(),
376+
"/srv/site",
377+
&config,
378+
);
379+
assert_eq!(checked.results[0].status, "checked");
380+
assert_eq!(checked.results[0].local_version.as_deref(), Some("1.2.3"));
381+
assert_eq!(checked.results[0].remote_version.as_deref(), Some("1.3.0"));
382+
383+
let planned = run_dry_run_mode(
384+
&[component],
385+
&local_versions,
386+
&remote_versions,
387+
&Project::default(),
388+
"/srv/site",
389+
&config,
390+
)
391+
.expect("dry-run must report rather than refuse a remote-newer version");
392+
assert_eq!(planned.results[0].status, "planned");
393+
assert_eq!(planned.results[0].local_version.as_deref(), Some("1.2.3"));
394+
assert_eq!(planned.results[0].remote_version.as_deref(), Some("1.3.0"));
395+
}
396+
338397
fn git(path: &Path, args: &[&str]) {
339398
let output = Command::new("git")
340399
.args(args)

0 commit comments

Comments
 (0)