Skip to content

Commit c2c6d40

Browse files
authored
fix: harden release packaging and unsafe autofix paths (#1037)
1 parent 816f74d commit c2c6d40

5 files changed

Lines changed: 82 additions & 22 deletions

File tree

src/core/refactor/auto/policy.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,14 @@ pub fn apply_fix_policy(
217217
.decompose_plans
218218
.retain(|p| !policy.exclude.contains(&p.source_finding));
219219

220+
// Structural decompose writes are still too risky for unattended autofix.
221+
// Keep them visible in dry-run output, but do not auto-apply them in write
222+
// mode until the engine is proven safe on real branches.
223+
if write {
224+
summary.dropped_plan_only += result.decompose_plans.len();
225+
result.decompose_plans.clear();
226+
}
227+
220228
result.total_insertions = summary.visible_insertions + summary.visible_new_files;
221229
summary
222230
}

src/core/refactor/auto/summary.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ pub fn summarize_audit_fix_result(
6363
}
6464
}
6565

66+
for plan in &fix_result.decompose_plans {
67+
if plan.applied {
68+
files.insert(plan.file.clone());
69+
let rule = format!("{:?}", plan.source_finding).to_lowercase();
70+
*rule_counts.entry(rule).or_insert(0) += 1;
71+
total_fixes += 1;
72+
}
73+
}
74+
6675
let rules = rule_counts
6776
.into_iter()
6877
.map(|(rule, count)| RuleFixCount { rule, count })

src/core/refactor/plan/generate/test_gen_fixes.rs

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -371,27 +371,23 @@ pub(crate) fn generate_test_method_fixes(
371371
}
372372
};
373373

374-
// If the generated test code uses Default::default() fallbacks, the
375-
// type wasn't properly resolved and the test is likely meaningless.
376-
// Downgrade to PlanOnly so it requires human review instead of auto-applying.
374+
// Missing test-method generation is still not trustworthy enough for
375+
// unattended CI autofix. Even without obvious unresolved-type fallbacks,
376+
// generated methods can still drift semantically from the target source
377+
// method or become invalid after branch sync.
377378
let has_unresolved_types =
378379
append_code.contains("Default::default()") || append_code.contains("::default()");
379-
let safety_tier = if has_unresolved_types {
380-
FixSafetyTier::PlanOnly
381-
} else {
382-
FixSafetyTier::Safe
383-
};
384380

385381
let insertions = vec![Insertion {
386382
kind: InsertionKind::MethodStub,
387383
finding: AuditFinding::MissingTestMethod,
388-
safety_tier,
389-
auto_apply: !has_unresolved_types,
390-
blocked_reason: if has_unresolved_types {
391-
Some("Generated test uses Default::default() fallback — types not resolved, test may be meaningless".to_string())
384+
safety_tier: FixSafetyTier::PlanOnly,
385+
auto_apply: false,
386+
blocked_reason: Some(if has_unresolved_types {
387+
"Generated test uses Default::default() fallback — types not resolved, test may be meaningless".to_string()
392388
} else {
393-
None
394-
},
389+
"Generated test methods require human review before writing".to_string()
390+
}),
395391
preflight: None,
396392
code: append_code,
397393
description: format!(

src/core/release/executor.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::extension::{self, ExtensionManifest};
99
use crate::{changelog, version};
1010

1111
use super::types::{ReleaseContext, ReleaseStepType};
12-
use super::utils::extract_latest_notes;
12+
use super::utils::{extract_latest_notes, parse_release_artifacts};
1313

1414
pub(crate) struct ReleaseStepExecutor {
1515
component_id: String,
@@ -277,6 +277,7 @@ impl ReleaseStepExecutor {
277277

278278
let exit_code = response
279279
.get("exit_code")
280+
.or_else(|| response.get("exitCode"))
280281
.and_then(|v| v.as_i64())
281282
.unwrap_or(-1);
282283

@@ -303,13 +304,13 @@ impl ReleaseStepExecutor {
303304
return Err(Error::internal_unexpected(detail));
304305
}
305306

306-
let artifacts: Vec<super::types::ReleaseArtifact> =
307-
serde_json::from_str(stdout).map_err(|e| {
308-
Error::internal_json(
309-
e.to_string(),
310-
Some(format!("Failed to parse package artifacts: {}", stdout)),
311-
)
312-
})?;
307+
let raw_artifacts: serde_json::Value = serde_json::from_str(stdout).map_err(|e| {
308+
Error::internal_json(
309+
e.to_string(),
310+
Some(format!("Failed to parse package artifacts: {}", stdout)),
311+
)
312+
})?;
313+
let artifacts = parse_release_artifacts(&raw_artifacts)?;
313314

314315
let mut context = self.context.lock().map_err(|_| {
315316
Error::internal_unexpected("Failed to lock release context".to_string())

src/core/release/workflow.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::component;
22
use crate::deploy::{self, DeployConfig};
3+
use crate::engine::command;
34
use crate::error::{Error, Result};
45
use crate::git;
56

@@ -23,6 +24,10 @@ pub fn run_command(input: ReleaseCommandInput) -> Result<(ReleaseCommandResult,
2324
},
2425
)?;
2526

27+
if !input.dry_run {
28+
ensure_release_on_default_branch(&component.local_path)?;
29+
}
30+
2631
let monorepo = git::MonorepoContext::detect(&component.local_path, &input.component_id);
2732
let (auto_bump_type, releasable_count) =
2833
match resolve_bump(&component.local_path, monorepo.as_ref())? {
@@ -288,6 +293,47 @@ fn format_tag(version: &str, monorepo: Option<&git::MonorepoContext>) -> String
288293
}
289294
}
290295

296+
fn ensure_release_on_default_branch(local_path: &str) -> Result<()> {
297+
let current_branch =
298+
command::run_in_optional(local_path, "git", &["symbolic-ref", "--short", "HEAD"])
299+
.ok_or_else(|| {
300+
Error::validation_invalid_argument(
301+
"release",
302+
"Refusing to release from detached HEAD",
303+
None,
304+
Some(vec![
305+
"Check out the default branch before releasing".to_string()
306+
]),
307+
)
308+
})?;
309+
310+
let default_branch = command::run_in_optional(
311+
local_path,
312+
"git",
313+
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
314+
)
315+
.map(|value| value.trim().trim_start_matches("origin/").to_string())
316+
.filter(|value| !value.is_empty())
317+
.unwrap_or_else(|| "main".to_string());
318+
319+
if current_branch == default_branch {
320+
return Ok(());
321+
}
322+
323+
Err(Error::validation_invalid_argument(
324+
"release",
325+
format!(
326+
"Refusing to release from non-default branch '{}' (default: '{}')",
327+
current_branch, default_branch
328+
),
329+
None,
330+
Some(vec![
331+
format!("Check out '{}' before releasing", default_branch),
332+
"If you only want a preview, use --dry-run".to_string(),
333+
]),
334+
))
335+
}
336+
291337
fn extract_new_version_from_plan(plan: &ReleasePlan) -> Option<String> {
292338
plan.steps
293339
.iter()

0 commit comments

Comments
 (0)