Skip to content

Commit 264d7bd

Browse files
authored
Reuse the release artifact during deployment (#8181)
* fix: deploy the prepared release artifact * style: format prepared artifact selection * test: restore release artifact preflight fixture
1 parent 94722f6 commit 264d7bd

10 files changed

Lines changed: 536 additions & 41 deletions

File tree

src/commands/deploy.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ fn build_config(args: &DeployArgs, skip_build: bool) -> DeployConfig {
370370
head: args.head,
371371
requested_ref: args.requested_ref.clone(),
372372
tagged: args.tagged,
373+
prepared_artifact: None,
373374
}
374375
}
375376

src/core/deploy/execution/mod.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ mod tests {
5555
artifact_requires_component_extract_command, resolve_preflight_artifact_path,
5656
validate_predeploy_artifact_version,
5757
};
58-
use super::prepare::failed_component_deploy_result;
58+
use super::prepare::{failed_component_deploy_result, prepare_component_deploy};
5959
use super::release_plan::{release_artifact_plan, should_try_download_release_artifact};
6060
use super::strategies::cleanup_deploy_build_artifact;
6161
use super::{bound_captured_read, ReleaseArtifactPlan, ARTIFACT_VERSION_READ_LIMIT_BYTES};
62-
use crate::core::component::{ArtifactInput, Component, VersionTarget};
63-
use crate::core::deploy::types::DeployConfig;
62+
use crate::core::component::{ArtifactInput, Component, ComponentScriptsConfig, VersionTarget};
63+
use crate::core::deploy::types::{DeployConfig, PreparedDeployArtifact};
64+
use crate::core::project::Project;
6465
use std::io::Write;
6566
use std::process::Command;
6667

@@ -116,6 +117,70 @@ mod tests {
116117
assert_eq!(result.error.as_deref(), Some("deploy failed"));
117118
}
118119

120+
#[test]
121+
fn prepared_artifact_skips_local_build_invocation() {
122+
let temp = tempfile::tempdir().expect("tempdir");
123+
let durable_artifact = temp.path().join("release.tar.gz");
124+
let build_counter = temp.path().join("build-count");
125+
std::fs::write(&durable_artifact, "release payload").expect("artifact");
126+
let mut component = Component {
127+
id: "fixture".to_string(),
128+
local_path: temp.path().display().to_string(),
129+
remote_path: "plugins/fixture".to_string(),
130+
build_artifact: Some("build/fixture.tar.gz".to_string()),
131+
extract_command: Some("tar -xzf {{artifact}}".to_string()),
132+
..Component::default()
133+
};
134+
component.scripts = Some(ComponentScriptsConfig {
135+
build: vec![format!("printf build >> {}", build_counter.display())],
136+
..ComponentScriptsConfig::default()
137+
});
138+
let config = DeployConfig {
139+
component_ids: vec![component.id.clone()],
140+
all: false,
141+
outdated: false,
142+
behind_upstream: false,
143+
dry_run: false,
144+
check: false,
145+
force: true,
146+
skip_build: true,
147+
keep_deps: false,
148+
skip_deps_hydration: false,
149+
expected_version: Some("1.2.3".to_string()),
150+
no_pull: true,
151+
head: false,
152+
requested_ref: None,
153+
tagged: false,
154+
prepared_artifact: Some(PreparedDeployArtifact {
155+
component_id: component.id.clone(),
156+
path: "build/fixture.tar.gz".to_string(),
157+
durable_path: durable_artifact.display().to_string(),
158+
size_bytes: 15,
159+
sha256: crate::core::deploy::sha256_file(&durable_artifact).expect("sha"),
160+
version: "1.2.3".to_string(),
161+
tag: "v1.2.3".to_string(),
162+
source_commit: "0123456789abcdef".to_string(),
163+
}),
164+
};
165+
166+
let prepared = prepare_component_deploy(
167+
&component,
168+
&config,
169+
"/srv/site",
170+
&Project::default(),
171+
Some("1.2.3".to_string()),
172+
None,
173+
None,
174+
)
175+
.expect("prepared artifact should pass local preflight");
176+
177+
assert_eq!(prepared.artifact_path, Some(durable_artifact));
178+
assert!(
179+
!build_counter.exists(),
180+
"prepared deployment must not build"
181+
);
182+
}
183+
119184
#[test]
120185
fn archive_artifact_without_component_extract_is_allowed_by_deploy_override() {
121186
assert!(!artifact_requires_component_extract_command(
@@ -165,6 +230,7 @@ mod tests {
165230
head: false,
166231
requested_ref: None,
167232
tagged: false,
233+
prepared_artifact: None,
168234
};
169235

170236
let result = resolve_preflight_artifact_path(
@@ -225,6 +291,7 @@ mod tests {
225291
head: true,
226292
requested_ref: None,
227293
tagged: false,
294+
prepared_artifact: None,
228295
};
229296

230297
assert!(!should_try_download_release_artifact(
@@ -258,6 +325,7 @@ mod tests {
258325
head: false,
259326
requested_ref: None,
260327
tagged: true,
328+
prepared_artifact: None,
261329
};
262330

263331
assert!(!should_try_download_release_artifact(
@@ -302,6 +370,7 @@ mod tests {
302370
head: false,
303371
requested_ref: None,
304372
tagged: false,
373+
prepared_artifact: None,
305374
};
306375

307376
assert!(should_try_download_release_artifact(
@@ -346,6 +415,7 @@ mod tests {
346415
head: false,
347416
requested_ref: None,
348417
tagged: false,
418+
prepared_artifact: None,
349419
};
350420

351421
assert!(should_try_download_release_artifact(
@@ -381,6 +451,7 @@ mod tests {
381451
head: false,
382452
requested_ref: None,
383453
tagged: false,
454+
prepared_artifact: None,
384455
};
385456

386457
match release_artifact_plan(&component, &config, false, false) {
@@ -429,6 +500,7 @@ mod tests {
429500
head: false,
430501
requested_ref: None,
431502
tagged: false,
503+
prepared_artifact: None,
432504
};
433505

434506
assert!(should_try_download_release_artifact(
@@ -502,6 +574,7 @@ mod tests {
502574
head: true,
503575
requested_ref: None,
504576
tagged: false,
577+
prepared_artifact: None,
505578
};
506579

507580
let artifact = resolve_preflight_artifact_path(

src/core/deploy/execution/prepare.rs

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,63 @@ pub(crate) fn prepare_component_deploy(
4444
let is_git_deploy = component.deploy_strategy.as_deref() == Some("git");
4545
let is_file_deploy = component.deploy_strategy.as_deref() == Some("file");
4646

47+
if let Some(prepared_artifact) = config.prepared_artifact.as_ref() {
48+
if is_git_deploy || is_file_deploy {
49+
return Err(failed_component_deploy_result(
50+
component,
51+
base_path,
52+
local_version,
53+
remote_version,
54+
None,
55+
"Prepared artifacts require an artifact deploy strategy".to_string(),
56+
));
57+
}
58+
if let Err(error) =
59+
prepared_artifact.validate(&component.id, config.expected_version.as_deref())
60+
{
61+
return Err(failed_component_deploy_result(
62+
component,
63+
base_path,
64+
local_version,
65+
remote_version,
66+
None,
67+
error.to_string(),
68+
));
69+
}
70+
}
71+
4772
// Try downloading release artifact from GitHub instead of building locally.
4873
// This is the preferred path when the component has remote_url set.
49-
let release_artifact: Option<PathBuf> =
74+
let release_artifact: Option<PathBuf> = if let Some(prepared_artifact) =
75+
config.prepared_artifact.as_ref()
76+
{
77+
Some(PathBuf::from(prepared_artifact.effective_path()))
78+
} else {
5079
match release_artifact_plan(component, config, is_git_deploy, is_file_deploy) {
51-
ReleaseArtifactPlan::Reuse { tag, .. } => match release_artifact {
52-
Some(artifact) => Some(artifact.path),
53-
None => return Err(failed_component_deploy_result(
54-
component, base_path, local_version, remote_version, None,
55-
format!("artifact source release_asset failed for '{}' tag {tag}: verified run-scoped artifact is unavailable. Refusing to fall back to local_build", component.id),
56-
).with_artifact_source(DeployArtifactSource::ReleaseAsset)),
57-
},
58-
ReleaseArtifactPlan::LocalBuild { reason } => {
59-
if config.dry_run {
60-
log_status!(
61-
"deploy",
62-
"Local rebuild planned for '{}': {}",
63-
component.id,
64-
reason
65-
);
80+
ReleaseArtifactPlan::Reuse { tag, .. } => match release_artifact {
81+
Some(artifact) => Some(artifact.path),
82+
None => return Err(failed_component_deploy_result(
83+
component, base_path, local_version, remote_version, None,
84+
format!("artifact source release_asset failed for '{}' tag {tag}: verified run-scoped artifact is unavailable. Refusing to fall back to local_build", component.id),
85+
).with_artifact_source(DeployArtifactSource::ReleaseAsset)),
86+
},
87+
ReleaseArtifactPlan::LocalBuild { reason } => {
88+
if config.dry_run {
89+
log_status!(
90+
"deploy",
91+
"Local rebuild planned for '{}': {}",
92+
component.id,
93+
reason
94+
);
95+
}
96+
None
6697
}
67-
None
6898
}
69-
};
99+
};
70100
let artifact_source = if is_git_deploy || is_file_deploy {
71101
None
102+
} else if config.prepared_artifact.is_some() {
103+
Some(DeployArtifactSource::Prepared)
72104
} else if release_artifact.is_some() {
73105
Some(DeployArtifactSource::ReleaseAsset)
74106
} else {
@@ -184,7 +216,10 @@ pub(crate) fn prepare_component_deploy(
184216
generated_cleanup_guard.disarm();
185217

186218
let cleanup_local_artifact = artifact_path.as_ref().is_some_and(|_| {
187-
release_artifact.is_none() && !config.skip_build && !is_self_deploy(component)
219+
config.prepared_artifact.is_none()
220+
&& release_artifact.is_none()
221+
&& !config.skip_build
222+
&& !is_self_deploy(component)
188223
});
189224

190225
// Capture provenance now, while the source tree is at the built state and the
@@ -195,6 +230,8 @@ pub(crate) fn prepare_component_deploy(
195230
BuildSource::GitPush
196231
} else if is_file_deploy {
197232
BuildSource::FileCopy
233+
} else if config.prepared_artifact.is_some() {
234+
BuildSource::PreparedArtifact
198235
} else if release_artifact.is_some() {
199236
BuildSource::DownloadedRelease
200237
} else if config.skip_build {

src/core/deploy/mod.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ pub use planning::{
2121
bucket_release_states, calculate_release_state, calculate_release_state_from_baseline,
2222
classify_release_state,
2323
};
24+
pub(crate) use types::sha256_file;
2425
pub use types::{
2526
compare_deployed_versions, parse_bulk_component_ids, ComponentDeployResult, ComponentStatus,
2627
DeployConfig, DeployOrchestrationResult, DeployReason, DeploySummary, MultiDeployResult,
27-
MultiDeploySummary, ProjectDeployResult, ReleaseState, ReleaseStateBuckets, ReleaseStateStatus,
28+
MultiDeploySummary, PreparedDeployArtifact, ProjectDeployResult, ReleaseState,
29+
ReleaseStateBuckets, ReleaseStateStatus,
2830
};
2931
pub use version_overrides::fetch_remote_versions;
3032
pub use version_overrides::{RemoteVersionProbeFailure, RemoteVersionProbeResult};
@@ -78,9 +80,8 @@ pub fn fetch_project_remote_versions(
7880

7981
/// Deploy components across multiple projects.
8082
///
81-
/// Builds each project independently. Keeping build artifacts local to each
82-
/// project run avoids lifecycle coupling between deploy target cleanup and
83-
/// later projects in the same command.
83+
/// Builds each project independently unless an upstream workflow supplies a
84+
/// prepared artifact, which is validated once and reused unchanged for every target.
8485
///
8586
/// Unknown project IDs are skipped (not fatal) — fleet configs can
8687
/// accumulate stale references that shouldn't block the rest.
@@ -134,6 +135,12 @@ pub fn run_multi(
134135
));
135136
}
136137

138+
if let Some(prepared_artifact) = config.prepared_artifact.as_ref() {
139+
for component_id in component_ids {
140+
prepared_artifact.validate(component_id, config.expected_version.as_deref())?;
141+
}
142+
}
143+
137144
log_status!(
138145
"deploy",
139146
"Deploying {:?} to {} project(s){}...",
@@ -189,6 +196,7 @@ pub fn run_multi(
189196
head: config.head,
190197
requested_ref: config.requested_ref.clone(),
191198
tagged: config.tagged,
199+
prepared_artifact: config.prepared_artifact.clone(),
192200
};
193201

194202
match run_with_release_artifacts(project_id, &project_config, &mut release_artifacts) {
@@ -306,6 +314,7 @@ mod tests {
306314
use super::*;
307315
use crate::core::project::{Project, ProjectComponentAttachment};
308316
use crate::test_support::with_isolated_home;
317+
use std::path::Path;
309318

310319
fn deploy_config() -> DeployConfig {
311320
DeployConfig {
@@ -326,6 +335,7 @@ mod tests {
326335
head: false,
327336
requested_ref: None,
328337
tagged: false,
338+
prepared_artifact: None,
329339
}
330340
}
331341

@@ -356,4 +366,37 @@ mod tests {
356366
}));
357367
});
358368
}
369+
370+
#[test]
371+
fn prepared_artifact_mismatch_fails_before_project_ssh_resolution() {
372+
with_isolated_home(|_| {
373+
project::save(&Project {
374+
id: "site".to_string(),
375+
..Project::default()
376+
})
377+
.expect("save project");
378+
let missing_path = Path::new("/definitely/missing/prepared-artifact.zip");
379+
let config = DeployConfig {
380+
prepared_artifact: Some(PreparedDeployArtifact {
381+
component_id: "plugin".to_string(),
382+
path: missing_path.display().to_string(),
383+
durable_path: missing_path.display().to_string(),
384+
size_bytes: 0,
385+
sha256: "not-a-real-sha".to_string(),
386+
version: "1.2.3".to_string(),
387+
tag: "v1.2.3".to_string(),
388+
source_commit: "0123456789abcdef".to_string(),
389+
}),
390+
..deploy_config()
391+
};
392+
393+
let error = run_multi(&["site".to_string()], &["plugin".to_string()], &config)
394+
.expect_err("missing prepared artifact must stop before any target mutation");
395+
396+
assert!(
397+
error.details.to_string().contains("prepared-artifact.zip"),
398+
"prepared artifact validation must fail before SSH resolution: {error:?}"
399+
);
400+
});
401+
}
359402
}

src/core/deploy/orchestration/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ pub(super) fn deploy_components(
325325
&["rev-parse", "--abbrev-ref", "HEAD"],
326326
)
327327
.map(|branch| format!("{} (HEAD)", branch))
328+
} else if let Some(prepared_artifact) = config.prepared_artifact.as_ref() {
329+
Some(prepared_artifact.tag.clone())
328330
} else {
329331
None
330332
};
@@ -340,6 +342,9 @@ pub(super) fn deploy_components(
340342
&identity.resolution_mode,
341343
);
342344
}
345+
if let Some(prepared_artifact) = config.prepared_artifact.clone() {
346+
result = result.with_prepared_artifact(prepared_artifact);
347+
}
343348

344349
// Attach explicit build provenance to every result, regardless of strategy.
345350
let mut build_provenance = prepared.build_provenance.clone();
@@ -348,6 +353,8 @@ pub(super) fn deploy_components(
348353
build_provenance.built_from_commit = Some(identity.resolved_sha.clone());
349354
} else if let Some(artifact) = resolved_release_artifacts.get(&component.id) {
350355
build_provenance.built_from_commit = artifact.commit.clone();
356+
} else if let Some(prepared_artifact) = config.prepared_artifact.as_ref() {
357+
build_provenance.built_from_commit = Some(prepared_artifact.source_commit.clone());
351358
}
352359
result = result.with_build_provenance(build_provenance);
353360

@@ -529,6 +536,7 @@ mod tests {
529536
head: false,
530537
requested_ref: None,
531538
tagged: false,
539+
prepared_artifact: None,
532540
}
533541
}
534542

0 commit comments

Comments
 (0)