use super::*;
use crate::commands::helpers;
use crate::pipeline;
use anodizer_core::config::Config;
use anodizer_core::context::Context;
use anodizer_core::git::short_commit_str;
use anodizer_core::log::StageLogger;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy)]
pub(super) enum HookKind {
Before,
After,
}
impl HookKind {
fn label(self) -> &'static str {
match self {
HookKind::Before => "before",
HookKind::After => "after",
}
}
}
pub(super) fn run_per_crate_lifecycle_hooks(
ctx: &Context,
crate_name: &str,
kind: HookKind,
dry_run: bool,
log: &StageLogger,
) -> Result<()> {
let label = kind.label();
if ctx.should_skip(label) {
return Ok(());
}
let Some(crate_cfg) = ctx.config.find_crate(crate_name) else {
return Ok(());
};
let block = match kind {
HookKind::Before => crate_cfg.before.as_ref(),
HookKind::After => crate_cfg.after.as_ref(),
};
let Some(hooks) = block
.and_then(|b| b.hooks.as_ref())
.filter(|h| !h.is_empty())
else {
return Ok(());
};
pipeline::run_hooks(
hooks,
label,
anodizer_core::hooks::HookRunContext::new(dry_run, log, Some(ctx.template_vars())),
)
}
pub(super) struct PerCrateOverlayGuard<'a> {
ctx: &'a mut Context,
saved_dist: std::path::PathBuf,
saved_selected_crates: Vec<String>,
saved_skip_stages: Vec<String>,
saved_tag: Option<String>,
saved_previous_tag: Option<String>,
saved_release_url: Option<String>,
saved_version_vars: Vec<(&'static str, Option<String>)>,
saved_overlay: OverlayFields,
}
pub(super) const VERSION_TEMPLATE_VARS: &[&str] = &[
"Version",
"RawVersion",
"Base",
"Major",
"Minor",
"Patch",
"Prerelease",
"BuildMetadata",
];
#[derive(Clone)]
pub(super) struct OverlayFields {
crates: Vec<anodizer_core::config::CrateConfig>,
workspaces: Option<Vec<anodizer_core::config::WorkspaceConfig>>,
changelog: Option<anodizer_core::config::ChangelogConfig>,
signs: Vec<anodizer_core::config::SignConfig>,
binary_signs: Vec<anodizer_core::config::SignConfig>,
before: Option<anodizer_core::config::HooksConfig>,
after: Option<anodizer_core::config::HooksConfig>,
env: Option<Vec<String>>,
}
impl OverlayFields {
pub(super) fn capture(config: &Config) -> Self {
Self {
crates: config.crates.clone(),
workspaces: config.workspaces.clone(),
changelog: config.changelog.clone(),
signs: config.signs.clone(),
binary_signs: config.binary_signs.clone(),
before: config.before.clone(),
after: config.after.clone(),
env: config.env.clone(),
}
}
pub(super) fn restore_into(&self, config: &mut Config) {
config.crates = self.crates.clone();
config.workspaces = self.workspaces.clone();
config.changelog = self.changelog.clone();
config.signs = self.signs.clone();
config.binary_signs = self.binary_signs.clone();
config.before = self.before.clone();
config.after = self.after.clone();
config.env = self.env.clone();
}
}
impl<'a> PerCrateOverlayGuard<'a> {
pub(super) fn capture(ctx: &'a mut Context) -> Self {
let saved_dist = ctx.config.dist.clone();
let saved_selected_crates = ctx.options.selected_crates.clone();
let saved_skip_stages = ctx.options.skip_stages.clone();
let saved_tag = ctx.template_vars().get("Tag").cloned();
let saved_previous_tag = ctx.template_vars().get("PreviousTag").cloned();
let saved_release_url = ctx.template_vars().get("ReleaseURL").cloned();
let saved_version_vars = VERSION_TEMPLATE_VARS
.iter()
.map(|&k| (k, ctx.template_vars().get(k).cloned()))
.collect();
let saved_overlay = OverlayFields::capture(&ctx.config);
Self {
ctx,
saved_dist,
saved_selected_crates,
saved_skip_stages,
saved_tag,
saved_previous_tag,
saved_release_url,
saved_version_vars,
saved_overlay,
}
}
pub(super) fn reset_overlay_fields(&mut self) {
let saved = self.saved_overlay.clone();
saved.restore_into(&mut self.ctx.config);
}
pub(super) fn snapshot_skip_stages(&self) -> &[String] {
&self.saved_skip_stages
}
pub(super) fn reset_version_vars(&mut self) {
for (key, value) in &self.saved_version_vars {
match value {
Some(v) => self.ctx.template_vars_mut().set(key, v),
None => {
self.ctx.template_vars_mut().unset(key);
}
}
}
}
pub(super) fn reset_release_url(&mut self) {
match &self.saved_release_url {
Some(v) => self.ctx.template_vars_mut().set("ReleaseURL", v),
None => {
self.ctx.template_vars_mut().unset("ReleaseURL");
}
}
}
pub(super) fn ctx_mut(&mut self) -> &mut Context {
self.ctx
}
}
impl Drop for PerCrateOverlayGuard<'_> {
fn drop(&mut self) {
let saved_overlay = self.saved_overlay.clone();
saved_overlay.restore_into(&mut self.ctx.config);
self.ctx.config.dist = std::mem::take(&mut self.saved_dist);
self.ctx.options.selected_crates = std::mem::take(&mut self.saved_selected_crates);
self.ctx.options.skip_stages = std::mem::take(&mut self.saved_skip_stages);
match self.saved_tag.take() {
Some(tag) => self.ctx.template_vars_mut().set("Tag", &tag),
None => {
self.ctx.template_vars_mut().unset("Tag");
}
}
match self.saved_previous_tag.take() {
Some(prev) => self.ctx.template_vars_mut().set("PreviousTag", &prev),
None => {
self.ctx.template_vars_mut().unset("PreviousTag");
}
}
match self.saved_release_url.take() {
Some(url) => self.ctx.template_vars_mut().set("ReleaseURL", &url),
None => {
self.ctx.template_vars_mut().unset("ReleaseURL");
}
}
for (key, value) in std::mem::take(&mut self.saved_version_vars) {
match value {
Some(v) => self.ctx.template_vars_mut().set(key, &v),
None => {
self.ctx.template_vars_mut().unset(key);
}
}
}
}
}
pub(super) fn apply_per_crate_version(
ctx: &mut Context,
crate_dist: &Path,
crate_name: &str,
log: &StageLogger,
) {
let Some(version) = peek_preserved_version(crate_dist) else {
return;
};
let semver = match anodizer_core::git::parse_semver(&version) {
Ok(sv) => sv,
Err(e) => {
log.verbose(&format!(
"preserved version '{version}' for crate '{crate_name}' \
is not strict semver ({e}); leaving Version vars unchanged"
));
return;
}
};
let vars = ctx.template_vars_mut();
vars.set("Version", &semver.version_string());
vars.set("RawVersion", &semver.raw_version_string());
vars.set("Base", &semver.raw_version_string());
vars.set("Major", &semver.major.to_string());
vars.set("Minor", &semver.minor.to_string());
vars.set("Patch", &semver.patch.to_string());
vars.set("Prerelease", semver.prerelease.as_deref().unwrap_or(""));
vars.set(
"BuildMetadata",
semver.build_metadata.as_deref().unwrap_or(""),
);
}
pub(super) fn peek_preserved_version(crate_dist: &Path) -> Option<String> {
let contexts = discover_preserved_contexts(crate_dist).ok()?;
contexts
.into_iter()
.map(|(_, c)| c.version)
.find(|v| !v.is_empty())
}
pub(super) fn apply_per_crate_tag(
ctx: &mut Context,
config: &Config,
crate_name: &str,
log: &StageLogger,
) {
let tag_template = config
.find_crate(crate_name)
.map(|c| {
c.tag_template
.clone()
.unwrap_or_else(|| format!("{crate_name}-v{{{{ Version }}}}"))
})
.filter(|t| !t.is_empty());
let Some(tag_template) = tag_template else {
return;
};
let tag = match ctx.render_template(&tag_template) {
Ok(t) if !t.is_empty() => t,
Ok(_) => return,
Err(e) => {
log.warn(&format!(
"failed to render tag_template '{tag_template}' for crate '{crate_name}': {e}"
));
return;
}
};
ctx.template_vars_mut().set("Tag", &tag);
let crate_prefix = anodizer_core::git::extract_tag_prefix(&tag_template);
let prefix = crate_prefix
.as_deref()
.or_else(|| config.monorepo_tag_prefix());
match anodizer_core::git::find_previous_tag_with_prefix(
&tag,
config.git.as_ref(),
Some(ctx.template_vars()),
prefix,
) {
Ok(Some(prev)) => ctx.template_vars_mut().set("PreviousTag", &prev),
Ok(None) => {
ctx.template_vars_mut().unset("PreviousTag");
}
Err(e) => log.verbose(&format!(
"previous-tag lookup for crate '{crate_name}' failed: {e}"
)),
}
}
pub(super) fn merge_workspace_skip(into: &mut Vec<String>, ws_skip: &[String]) {
for stage in ws_skip {
if !into.iter().any(|s| s == stage) {
into.push(stage.clone());
}
}
}
pub(super) fn run_one_crate_dist(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
opts: &RunOpts,
dist: PathBuf,
) -> Result<()> {
check_no_unsuffixed_suffixed_collision(&dist, "context")?;
check_no_unsuffixed_suffixed_collision(&dist, "artifacts")?;
let preserved_contexts = discover_preserved_contexts(&dist)?;
let preserved = merge_preserved_contexts(&preserved_contexts)?;
let shard_count = preserved_contexts.len();
log.status(&format!(
"loaded {} context manifest(s) (version={}, commit={}, targets=[{}], {} artifact(s))",
shard_count,
preserved.version,
short_commit_str(&preserved.commit),
preserved.targets.join(", "),
preserved.artifacts.len(),
));
hash_verify_preserved_dist(&preserved, &dist)?;
let ctx_commit = ctx
.template_vars()
.get("FullCommit")
.cloned()
.unwrap_or_default();
if ctx_commit.is_empty() {
anyhow::bail!(
"publish-only: current release context has no resolved commit. \
Run from a tagged commit (`git checkout {}`) before --publish-only.",
short_commit_str(&preserved.commit),
);
}
if ctx_commit != preserved.commit {
anyhow::bail!(
"publish-only: context manifest was preserved at commit {} but the current \
release context resolved to commit {}. Re-signing the preserved bytes \
under the current commit's tag would ship signatures that don't match \
the determinism-verified state. `git checkout {}` then retry.",
short_commit_str(&preserved.commit),
short_commit_str(&ctx_commit),
short_commit_str(&preserved.commit),
);
}
let artifact_manifests = discover_artifacts_manifests(&dist)?;
for manifest_path in &artifact_manifests {
helpers::load_artifacts_from_manifest(ctx, &dist, manifest_path).with_context(|| {
format!(
"publish-only: failed to load {} from {}. The preserve-dist \
flow normally copies these from the harness's worktree post-pipeline; \
if any is missing the preserved dist is incomplete.",
manifest_path.display(),
dist.display()
)
})?;
}
ctx.artifacts.dedupe_targetless_duplicates();
log.status(&format!(
"rehydrated {} artifact(s) from {} artifacts manifest(s)",
ctx.artifacts.all().len(),
artifact_manifests.len(),
));
detect_duplicate_artifact_paths(ctx)?;
strip_ephemeral_signatures(ctx, log);
crate::commands::helpers::detect_missing_files(
ctx.artifacts
.all()
.iter()
.filter(|a| {
!matches!(
a.kind,
anodizer_core::artifact::ArtifactKind::Binary
| anodizer_core::artifact::ArtifactKind::UniversalBinary
| anodizer_core::artifact::ArtifactKind::Metadata
) && !anodizer_core::artifact::is_directory_bundle_artifact(a)
})
.map(|a| a.path.as_path()),
&dist,
)?;
crate::commands::helpers::write_metadata_json(ctx, config, log)?;
let p = pipeline::build_publish_only_pipeline();
let result = p.run(ctx, log);
if result.is_ok() {
super::super::run_post_pipeline(ctx, config, opts.dry_run, log)?;
cleanup_shard_manifests(&dist, log);
}
if result.is_ok() {
super::super::gate_required_failures(ctx)?;
}
result
}
pub(super) fn strip_ephemeral_signatures(ctx: &mut Context, log: &StageLogger) {
use anodizer_core::artifact::ArtifactKind;
let stale_paths: Vec<std::path::PathBuf> = ctx
.artifacts
.all()
.iter()
.filter(|a| matches!(a.kind, ArtifactKind::Signature | ArtifactKind::Certificate))
.map(|a| a.path.clone())
.collect();
if stale_paths.is_empty() {
return;
}
let count = stale_paths.len();
log.status(&format!(
"stripping {count} ephemeral signature/certificate artifact(s) before re-sign"
));
ctx.artifacts.remove_by_paths(&stale_paths);
let mut disk_removed = 0usize;
for p in &stale_paths {
match std::fs::remove_file(p) {
Ok(()) => disk_removed += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => log.warn(&format!(
"failed to delete stale signature {}: {} \
(continuing; SignStage will overwrite or fail loudly)",
p.display(),
e
)),
}
}
log.status(&format!(
"stripped {count} ephemeral signature artifact(s) from registry \
({disk_removed} also deleted from disk)"
));
}
pub(super) fn detect_duplicate_artifact_paths(ctx: &Context) -> Result<()> {
crate::commands::helpers::detect_duplicate_paths(
ctx.artifacts.all().iter().map(|a| a.path.as_path()),
)
}