Skip to content

Commit c6dd3d7

Browse files
authored
fix: verify and reuse release deploy assets (#8191)
1 parent 1e824f3 commit c6dd3d7

6 files changed

Lines changed: 463 additions & 99 deletions

File tree

src/core/deploy/execution/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ mod strategies;
1010
pub(super) use prepare::{
1111
execute_preflighted_component_deploy, prepare_component_deploy, PreparedComponentDeploy,
1212
};
13-
pub(super) use release_plan::{release_artifact_plan, ReleaseArtifactPlan};
13+
pub(super) use release_plan::{
14+
release_artifact_plan, resolve_planned_release_artifact, ReleaseArtifactPlan,
15+
};
1416

1517
/// Maximum number of bytes retained when reading a version-target file out of a
1618
/// deploy artifact. The artifact is downloaded release content and therefore

src/core/deploy/execution/prepare.rs

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@ use super::super::generated_artifacts::GeneratedBuildArtifactCleanupGuard;
99
use super::super::path_roots::{component_remote_path, resolve_effective_remote_path};
1010
use super::super::policy::{owner_hint_for_path, protected_path_suffixes, validate_deploy_target};
1111
use super::super::provenance::capture_build_provenance;
12+
use super::super::release_download::ReleaseArtifact;
1213
use super::super::types::{
1314
BuildProvenance, BuildSource, ComponentDeployResult, DeployArtifactSource, DeployConfig,
1415
};
1516
use super::super::version_overrides::is_self_deploy;
1617
use super::preflight::{resolve_preflight_artifact_path, validate_preflight_file_artifact};
17-
use super::release_plan::{
18-
release_artifact_plan, try_download_release_artifact, ReleaseArtifactPlan,
19-
};
18+
use super::release_plan::{release_artifact_plan, ReleaseArtifactPlan};
2019
use super::strategies::{execute_artifact_deploy, execute_file_deploy, execute_git_deploy};
2120

2221
pub(crate) struct PreparedComponentDeploy {
@@ -40,6 +39,7 @@ pub(crate) fn prepare_component_deploy(
4039
project: &Project,
4140
local_version: Option<String>,
4241
remote_version: Option<String>,
42+
release_artifact: Option<ReleaseArtifact>,
4343
) -> std::result::Result<PreparedComponentDeploy, ComponentDeployResult> {
4444
let is_git_deploy = component.deploy_strategy.as_deref() == Some("git");
4545
let is_file_deploy = component.deploy_strategy.as_deref() == Some("file");
@@ -48,22 +48,13 @@ pub(crate) fn prepare_component_deploy(
4848
// This is the preferred path when the component has remote_url set.
4949
let release_artifact: Option<PathBuf> =
5050
match release_artifact_plan(component, config, is_git_deploy, is_file_deploy) {
51-
ReleaseArtifactPlan::Reuse { tag, .. } => {
52-
match try_download_release_artifact(component, &tag) {
53-
Ok(path) => path,
54-
Err(error) => {
55-
return Err(failed_component_deploy_result(
56-
component,
57-
base_path,
58-
local_version,
59-
remote_version,
60-
None,
61-
error,
62-
)
63-
.with_artifact_source(DeployArtifactSource::ReleaseAsset));
64-
}
65-
}
66-
}
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+
},
6758
ReleaseArtifactPlan::LocalBuild { reason } => {
6859
if config.dry_run {
6960
log_status!(

src/core/deploy/execution/release_plan.rs

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
use std::path::PathBuf;
2-
31
use crate::core::component::Component;
42
use crate::core::release;
53

@@ -76,34 +74,21 @@ pub(super) fn should_try_download_release_artifact(
7674
)
7775
}
7876

79-
/// Try to download a release artifact from GitHub for the selected deploy tag.
80-
///
81-
/// Returns `Ok(Some(path))` only when the planned release asset was downloaded.
82-
/// Once deploy has selected the release asset path, any download miss fails closed
83-
/// instead of silently rebuilding from the local checkout.
84-
pub(super) fn try_download_release_artifact(
77+
pub(crate) fn resolve_planned_release_artifact(
8578
component: &Component,
8679
tag: &str,
87-
) -> std::result::Result<Option<PathBuf>, String> {
88-
let Some(remote_url) = component.remote_url.as_ref() else {
89-
return Ok(None);
90-
};
91-
let Some(github) = release_download::parse_github_url(remote_url) else {
92-
return Ok(None);
93-
};
94-
let Some(artifact_name) = release_download::resolve_artifact_name(component) else {
95-
return Ok(None);
96-
};
97-
98-
log_status!(
99-
"deploy",
100-
"Attempting to download release artifact for '{}' tag {} from GitHub...",
101-
component.id,
102-
tag
103-
);
104-
105-
release_download::download_release_artifact(&github, &component.github, tag, &artifact_name)
106-
.map(Some)
80+
store: &mut release_download::ReleaseArtifactStore,
81+
) -> std::result::Result<release_download::ReleaseArtifact, String> {
82+
let remote_url = component
83+
.remote_url
84+
.as_deref()
85+
.ok_or_else(|| "component has no remote_url".to_string())?;
86+
let github = release_download::parse_github_url(remote_url)
87+
.ok_or_else(|| "component remote_url is not a GitHub repository URL".to_string())?;
88+
let artifact_name = release_download::resolve_artifact_name(component)
89+
.ok_or_else(|| "component has no build_artifact filename".to_string())?;
90+
store
91+
.resolve(&github, &component.github, tag, &artifact_name)
10792
.map_err(|error| release_asset_download_error(component, tag, &artifact_name, error))
10893
}
10994

src/core/deploy/mod.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,24 @@ use crate::core::project;
3939
/// This is the preferred entry point for callers - it handles project loading
4040
/// and SSH context resolution, keeping those details encapsulated.
4141
pub fn run(project_id: &str, config: &DeployConfig) -> Result<DeployOrchestrationResult> {
42+
let mut release_artifacts = release_download::ReleaseArtifactStore::default();
43+
run_with_release_artifacts(project_id, config, &mut release_artifacts)
44+
}
45+
46+
fn run_with_release_artifacts(
47+
project_id: &str,
48+
config: &DeployConfig,
49+
release_artifacts: &mut release_download::ReleaseArtifactStore,
50+
) -> Result<DeployOrchestrationResult> {
4251
let project = project::load(project_id)?;
43-
project::validate_deploy_component_local_paths(&project, &config.component_ids)?;
52+
// A version-pinned release asset is resolved remotely before orchestration;
53+
// requiring its configured checkout to exist would reintroduce a mutable
54+
// source gate. Other modes retain the existing early local-path validation.
55+
if config.expected_version.is_none() {
56+
project::validate_deploy_component_local_paths(&project, &config.component_ids)?;
57+
}
4458
let (ctx, base_path) = resolve_project_ssh_with_base_path(project_id)?;
45-
orchestration::deploy_components(config, &project, &ctx, &base_path)
59+
orchestration::deploy_components(config, &project, &ctx, &base_path, release_artifacts)
4660
}
4761

4862
/// Read deployed component versions without running deploy planning or git
@@ -137,6 +151,7 @@ pub fn run_multi(
137151
let mut failed: u32 = 0;
138152
let skipped: u32 = unknown_projects.len() as u32;
139153
let mut planned: u32 = 0;
154+
let mut release_artifacts = release_download::ReleaseArtifactStore::default();
140155
// Record skipped results for unknown projects
141156
for pid in &unknown_projects {
142157
project_results.push(ProjectDeployResult {
@@ -174,7 +189,7 @@ pub fn run_multi(
174189
tagged: config.tagged,
175190
};
176191

177-
match run(project_id, &project_config) {
192+
match run_with_release_artifacts(project_id, &project_config, &mut release_artifacts) {
178193
Ok(result) => {
179194
let deploy_failed = result.summary.failed > 0;
180195
let is_planned = config.dry_run || config.check;

src/core/deploy/orchestration/mod.rs

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ use crate::core::project::Project;
77
use crate::core::release::version;
88

99
use super::execution::{
10-
execute_preflighted_component_deploy, prepare_component_deploy, PreparedComponentDeploy,
10+
execute_preflighted_component_deploy, prepare_component_deploy, release_artifact_plan,
11+
resolve_planned_release_artifact, PreparedComponentDeploy, ReleaseArtifactPlan,
1112
};
1213
use super::orchestration_ref_checkout::{ExactRefCheckout, ExactRefIdentity};
1314
use super::orchestration_tag_checkout::{checkout_deploy_tags, restore_branches};
1415
use super::path_roots::{project_with_detected_path_roots, resolve_effective_remote_path};
1516
use super::planning::{load_project_components, plan_components};
17+
use super::release_download::{ReleaseArtifact, ReleaseArtifactStore};
1618
use super::types::{ComponentDeployResult, DeployConfig, DeployOrchestrationResult, DeploySummary};
1719
use super::version_overrides::fetch_remote_versions_for_project;
1820

@@ -34,6 +36,7 @@ pub(super) fn deploy_components(
3436
project: &Project,
3537
ctx: &RemoteProjectContext,
3638
base_path: &str,
39+
release_artifacts: &mut ReleaseArtifactStore,
3740
) -> Result<DeployOrchestrationResult> {
3841
let loaded = load_project_components(project, &config.component_ids, config.check)?;
3942
validate_supported_build_configs(&loaded.deployable)?;
@@ -105,6 +108,31 @@ pub(super) fn deploy_components(
105108

106109
validate_effective_remote_paths(&components, &project, base_path)?;
107110

111+
// Release assets are immutable remote inputs. Resolve and verify them before
112+
// touching any configured checkout, then reuse the same run-scoped bytes for
113+
// every target/project that requests this component.
114+
let mut resolved_release_artifacts: HashMap<String, ReleaseArtifact> = HashMap::new();
115+
for component in &components {
116+
if let ReleaseArtifactPlan::Reuse { tag, .. } =
117+
release_artifact_plan(component, config, false, false)
118+
{
119+
let artifact = resolve_planned_release_artifact(component, &tag, release_artifacts)
120+
.map_err(|error| {
121+
Error::validation_invalid_argument("releaseArtifact", error, None, None)
122+
})?;
123+
log_status!(
124+
"deploy",
125+
"Verified release asset: tag={} name={} size={} sha256={} source={}",
126+
artifact.tag,
127+
artifact.name,
128+
artifact.size,
129+
artifact.sha256,
130+
artifact.url
131+
);
132+
resolved_release_artifacts.insert(component.id.clone(), artifact);
133+
}
134+
}
135+
108136
// Resolve first, then materialize immutable detached worktrees for real deploys.
109137
// Dry-run resolves in `run_dry_run_mode` and never creates a worktree.
110138
let exact_ref_checkouts = if !config.dry_run {
@@ -172,35 +200,43 @@ pub(super) fn deploy_components(
172200
)?);
173201
}
174202

203+
// Only local builds require mutable checkout safety checks. Release assets are
204+
// resolved and verified above and must not read or alter a source checkout.
205+
let local_build_components: Vec<Component> = components
206+
.iter()
207+
.filter(|component| !resolved_release_artifacts.contains_key(&component.id))
208+
.cloned()
209+
.collect();
210+
175211
// Sync: pull latest changes before deploying (unless --no-pull or --skip-build)
176212
if config.requested_ref.is_none() && !config.no_pull && !config.skip_build {
177-
sync_components(&components)?;
213+
sync_components(&local_build_components)?;
178214
}
179215

180216
// Warn when --head deploys from a non-default branch (safety guardrail)
181217
if config.head && !config.skip_build {
182-
warn_non_default_branch(&components, config)?;
218+
warn_non_default_branch(&local_build_components, config)?;
183219
}
184220

185221
if config.requested_ref.is_none() && !config.force {
186-
check_uncommitted_changes(&components)?;
222+
check_uncommitted_changes(&local_build_components)?;
187223
}
188224

189225
// Check for HEAD-vs-tag gap before the tag checkout.
190226
if config.requested_ref.is_none() && !config.head && !config.skip_build {
191-
check_unreleased_commits(&components, config)?;
227+
check_unreleased_commits(&local_build_components, config)?;
192228
}
193229

194230
// Checkout the deploy tag for each component (unless --head or --skip-build).
195231
let tag_checkouts = if config.requested_ref.is_none() && !config.head && !config.skip_build {
196-
checkout_deploy_tags(&components, config.expected_version.as_deref())?
232+
checkout_deploy_tags(&local_build_components, config.expected_version.as_deref())?
197233
} else {
198234
Vec::new()
199235
};
200236

201237
// Verify expected version if --version was specified
202238
if let Some(ref expected) = config.expected_version {
203-
if let Err(err) = verify_expected_version(&components, expected) {
239+
if let Err(err) = verify_expected_version(&local_build_components, expected) {
204240
if !tag_checkouts.is_empty() {
205241
restore_branches(&tag_checkouts);
206242
}
@@ -221,6 +257,7 @@ pub(super) fn deploy_components(
221257
base_path,
222258
&local_versions,
223259
&remote_versions,
260+
&resolved_release_artifacts,
224261
) {
225262
Ok(prepared) => prepared,
226263
Err(failures) => {
@@ -255,6 +292,11 @@ pub(super) fn deploy_components(
255292
let exact_ref_identity = exact_ref_identities.get(&component.id);
256293
let deployed_ref = if let Some(identity) = exact_ref_identity {
257294
Some(identity.requested_ref.clone())
295+
} else if let Some(artifact) = resolved_release_artifacts.get(&component.id) {
296+
Some(match artifact.commit.as_deref() {
297+
Some(commit) => format!("{} ({commit})", artifact.tag),
298+
None => artifact.tag.clone(),
299+
})
258300
} else if let Some(checkout) = tag_checkouts
259301
.iter()
260302
.find(|c| c.component_id == component.id)
@@ -289,6 +331,8 @@ pub(super) fn deploy_components(
289331
build_provenance.built_from_ref = deployed_ref;
290332
if let Some(identity) = exact_ref_identity {
291333
build_provenance.built_from_commit = Some(identity.resolved_sha.clone());
334+
} else if let Some(artifact) = resolved_release_artifacts.get(&component.id) {
335+
build_provenance.built_from_commit = artifact.commit.clone();
292336
}
293337
result = result.with_build_provenance(build_provenance);
294338

@@ -353,6 +397,7 @@ fn prepare_component_deployments(
353397
base_path: &str,
354398
local_versions: &HashMap<String, String>,
355399
remote_versions: &HashMap<String, String>,
400+
release_artifacts: &HashMap<String, ReleaseArtifact>,
356401
) -> std::result::Result<Vec<PreparedComponentDeploy>, Vec<ComponentDeployResult>> {
357402
let mut prepared_deployments = Vec::new();
358403
let mut failures = Vec::new();
@@ -374,6 +419,7 @@ fn prepare_component_deployments(
374419
project,
375420
local_versions.get(&component.id).cloned(),
376421
remote_versions.get(&component.id).cloned(),
422+
release_artifacts.get(&component.id).cloned(),
377423
) {
378424
Ok(prepared) => prepared_deployments.push(prepared),
379425
Err(result) => failures.push(result),
@@ -1206,6 +1252,7 @@ mod tests {
12061252
"/srv/site",
12071253
&HashMap::new(),
12081254
&HashMap::new(),
1255+
&HashMap::new(),
12091256
) {
12101257
Ok(_) => panic!("a later missing artifact must abort the whole deploy batch"),
12111258
Err(failures) => failures,
@@ -1340,6 +1387,7 @@ mod tests {
13401387
"/srv/site",
13411388
&HashMap::new(),
13421389
&HashMap::new(),
1390+
&HashMap::new(),
13431391
)
13441392
.expect("prepare exact-ref artifact");
13451393

@@ -1478,6 +1526,7 @@ mod tests {
14781526
"/srv/site",
14791527
&HashMap::new(),
14801528
&HashMap::new(),
1529+
&HashMap::new(),
14811530
) {
14821531
Ok(_) => panic!("failed build should abort preflight"),
14831532
Err(failures) => failures,

0 commit comments

Comments
 (0)