use anyhow::{Context as _, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anodizer_core::artifact::ArtifactRegistry;
use anodizer_core::config::{Config, WorkspaceConfig};
use anodizer_core::context::Context;
use anodizer_core::git::short_commit_str;
use anodizer_core::log::StageLogger;
use super::helpers;
use crate::pipeline;
#[derive(Debug)]
pub(super) enum DistLayout {
Flat,
PerCrate(Vec<String>),
Ambiguous { crate_subdirs: Vec<String> },
}
pub(super) fn detect_dist_layout(dist: &Path, log: &StageLogger) -> Result<DistLayout> {
let has_flat = !discover_sharded_manifests(dist, "context")?.is_empty();
let mut crate_subdirs: Vec<String> = Vec::new();
let entries = std::fs::read_dir(dist).with_context(|| {
format!(
"publish-only: reading dist directory {} to detect layout",
dist.display()
)
})?;
for entry in entries {
let entry = entry?;
let is_dir = match entry.file_type() {
Ok(t) => t.is_dir(),
Err(e) => {
log.verbose(&format!(
"publish-only: stat of dist entry {} failed: {e}; treating as non-directory",
entry.path().display()
));
false
}
};
if !is_dir {
continue;
}
let subdir = entry.path();
if !discover_sharded_manifests(&subdir, "context")?.is_empty()
&& let Some(name) = entry.file_name().to_str()
{
crate_subdirs.push(name.to_string());
}
}
crate_subdirs.sort();
match (has_flat, crate_subdirs.is_empty()) {
(_, true) => Ok(DistLayout::Flat),
(false, false) => Ok(DistLayout::PerCrate(crate_subdirs)),
(true, false) => Ok(DistLayout::Ambiguous { crate_subdirs }),
}
}
pub(super) fn crate_subdir_has_manifest(dist: &Path, crate_name: &str, log: &StageLogger) -> bool {
let subdir = dist.join(crate_name);
if !subdir.is_dir() {
return false;
}
match discover_sharded_manifests(&subdir, "context") {
Ok(manifests) => !manifests.is_empty(),
Err(e) => {
log.verbose(&format!(
"publish-only --crate: scanning {} for context manifests failed: {e}; \
treating crate '{crate_name}' as having no per-crate subdir",
subdir.display()
));
false
}
}
}
const SIGN_ENV_VARS: &[&str] = &["COSIGN_KEY", "GPG_PRIVATE_KEY"];
const GITHUB_TOKEN_ENV_VARS: &[&str] = &["GITHUB_TOKEN", "ANODIZER_GITHUB_TOKEN"];
pub(super) struct RunOpts {
pub dry_run: bool,
pub no_preflight: bool,
pub silent_meta: bool,
}
pub(super) fn run(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
opts: RunOpts,
) -> Result<()> {
log.status("running in publish-only mode (load preserved dist + sign + publish)...");
let dist = config.dist.clone();
run_one_crate_dist(ctx, config, log, &opts, dist)
}
pub(super) fn run_per_crate(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
opts: RunOpts,
dist_base: PathBuf,
crate_order: Vec<String>,
) -> Result<()> {
log.status(&format!(
"publish-only (per-crate): iterating {} crate(s): {}",
crate_order.len(),
crate_order.join(", ")
));
if opts.dry_run {
log.verbose("(dry-run) skipping production-credential preflight");
} else if opts.no_preflight {
log.warn(
"credential preflight skipped via --no-preflight; \
missing credentials will fail mid-pipeline (no idempotent recovery)",
);
} else {
preflight_credentials(|k| ctx.env_var(k))?;
}
let workspace_for: HashMap<String, &WorkspaceConfig> = config
.workspaces
.as_deref()
.map(|ws_list| {
let mut idx = HashMap::new();
for ws in ws_list {
for c in &ws.crates {
idx.insert(c.name.clone(), ws);
}
}
idx
})
.unwrap_or_default();
let mut guard = PerCrateOverlayGuard::capture(ctx);
let baseline_skip_stages = guard.snapshot_skip_stages().to_vec();
for crate_name in &crate_order {
let crate_dist = dist_base.join(crate_name);
log.status(&format!(
"publish-only: publishing crate '{crate_name}' from {}",
crate_dist.display()
));
guard.reset_overlay_fields();
guard.reset_version_vars();
let ctx = guard.ctx_mut();
ctx.artifacts = ArtifactRegistry::new();
ctx.options.skip_stages = baseline_skip_stages.clone();
if let Some(ws) = workspace_for.get(crate_name.as_str()) {
crate::commands::helpers::apply_workspace_overlay(&mut ctx.config, ws);
merge_workspace_skip(&mut ctx.options.skip_stages, &ws.skip);
}
ctx.config.dist = crate_dist.clone();
ctx.options.selected_crates = vec![crate_name.clone()];
apply_per_crate_version(ctx, &crate_dist, crate_name, log);
apply_per_crate_tag(ctx, config, crate_name, log);
let per_crate_opts = RunOpts {
dry_run: opts.dry_run,
no_preflight: true,
silent_meta: true,
};
run_one_crate_dist(ctx, config, log, &per_crate_opts, crate_dist)?;
}
drop(guard);
Ok(())
}
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_version_vars: Vec<(&'static str, Option<String>)>,
saved_overlay: OverlayFields,
}
const VERSION_TEMPLATE_VARS: &[&str] = &[
"Version",
"RawVersion",
"Base",
"Major",
"Minor",
"Patch",
"Prerelease",
"BuildMetadata",
];
#[derive(Clone)]
struct OverlayFields {
crates: Vec<anodizer_core::config::CrateConfig>,
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 {
fn capture(config: &Config) -> Self {
Self {
crates: config.crates.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(),
}
}
fn restore_into(&self, config: &mut Config) {
config.crates = self.crates.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> {
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_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_version_vars,
saved_overlay,
}
}
fn reset_overlay_fields(&mut self) {
let saved = self.saved_overlay.clone();
saved.restore_into(&mut self.ctx.config);
}
fn snapshot_skip_stages(&self) -> &[String] {
&self.saved_skip_stages
}
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);
}
}
}
}
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");
}
}
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);
}
}
}
}
}
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!(
"publish-only: 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(""),
);
}
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())
}
fn apply_per_crate_tag(ctx: &mut Context, config: &Config, crate_name: &str, log: &StageLogger) {
let tag_template = ctx
.config
.crates
.iter()
.find(|c| c.name == crate_name)
.or_else(|| {
config
.workspaces
.as_deref()
.into_iter()
.flatten()
.flat_map(|ws| ws.crates.iter())
.find(|c| c.name == crate_name)
})
.map(|c| c.tag_template.clone());
let Some(tag_template) = tag_template.filter(|t| !t.is_empty()) else {
return;
};
let tag = match ctx.render_template(&tag_template) {
Ok(t) if !t.is_empty() => t,
Ok(_) => return,
Err(e) => {
log.warn(&format!(
"publish-only: 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!(
"publish-only: previous-tag lookup for crate '{crate_name}' failed: {e}"
)),
}
}
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());
}
}
}
fn run_one_crate_dist(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
opts: &RunOpts,
dist: PathBuf,
) -> Result<()> {
if opts.dry_run {
if !opts.silent_meta {
log.verbose("(dry-run) skipping production-credential preflight");
}
} else if opts.no_preflight {
if !opts.silent_meta {
log.warn(
"credential preflight skipped via --no-preflight; \
missing credentials will fail mid-pipeline (no idempotent recovery)",
);
}
} else {
preflight_credentials(|k| ctx.env_var(k))?;
}
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!(
"publish-only: 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!(
"publish-only: 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
)
})
.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::run_post_pipeline(ctx, config, opts.dry_run, log)?;
cleanup_shard_manifests(&dist, log);
}
if result.is_ok() {
super::gate_required_failures(ctx)?;
}
result
}
fn preflight_credentials(env: impl Fn(&str) -> Option<String>) -> Result<()> {
let token_present = GITHUB_TOKEN_ENV_VARS
.iter()
.any(|v| env(v).map(|s| !s.is_empty()).unwrap_or(false));
let sign_key_present = SIGN_ENV_VARS
.iter()
.any(|v| env(v).map(|s| !s.is_empty()).unwrap_or(false));
if !token_present {
anyhow::bail!(
"publish-only: missing release token. Set one of {} before running --publish-only \
(or pass --dry-run to preview without secrets).",
GITHUB_TOKEN_ENV_VARS.join(" / "),
);
}
if !sign_key_present {
anyhow::bail!(
"publish-only: missing production signing key. Set at least one of {} before \
running --publish-only (or pass --dry-run to preview without secrets). \
The harness's ephemeral signatures are NOT shippable — this mode exists \
to overlay production signatures on the byte-stable artifacts.",
SIGN_ENV_VARS.join(" / "),
);
}
Ok(())
}
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!(
"publish-only: 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!(
"publish-only: failed to delete stale signature {}: {} \
(continuing; SignStage will overwrite or fail loudly)",
p.display(),
e
)),
}
}
log.status(&format!(
"publish-only: stripped {count} ephemeral signature artifact(s) from registry \
({disk_removed} also deleted from disk)"
));
}
fn detect_duplicate_artifact_paths(ctx: &Context) -> Result<()> {
crate::commands::helpers::detect_duplicate_paths(
ctx.artifacts.all().iter().map(|a| a.path.as_path()),
)
}
#[derive(serde::Deserialize, Debug, Default, Clone)]
struct PreservedDistContext {
#[serde(default)]
artifacts: Vec<PreservedArtifact>,
#[serde(default)]
targets: Vec<String>,
#[serde(default)]
version: String,
#[serde(default)]
commit: String,
}
#[derive(serde::Deserialize, Debug, Default, Clone)]
struct PreservedArtifact {
#[serde(default)]
name: String,
#[serde(default)]
path: String,
#[serde(default)]
sha256: String,
#[serde(default)]
size: u64,
}
fn discover_sharded_manifests(dist: &Path, base: &str) -> Result<Vec<PathBuf>> {
let entries = std::fs::read_dir(dist).with_context(|| {
format!(
"publish-only: reading dist directory {} to discover {} manifest(s)",
dist.display(),
base,
)
})?;
let exact = format!("{base}.json");
let prefix = format!("{base}-");
let mut found: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry.with_context(|| {
format!(
"publish-only: reading directory entry under {}",
dist.display()
)
})?;
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let name = entry.file_name();
let name = match name.to_str() {
Some(n) => n,
None => continue,
};
if name.ends_with(".tmp") {
continue;
}
if name == exact || (name.starts_with(&prefix) && name.ends_with(".json")) {
found.push(entry.path());
}
}
found.sort();
Ok(found)
}
fn discover_preserved_contexts(dist: &Path) -> Result<Vec<(PathBuf, PreservedDistContext)>> {
let found = discover_sharded_manifests(dist, "context")?;
if found.is_empty() {
anyhow::bail!(
"publish-only: no context.json (or context-<shard>.json) found at {}. \
Run `anodize check determinism --preserve-dist=<dist-dir>` on a green \
determinism check first, or use `anodize publish` (no sign step) if \
you only need the publisher pass.",
dist.display()
);
}
let mut out: Vec<(PathBuf, PreservedDistContext)> = Vec::with_capacity(found.len());
for path in found {
let parsed = load_preserved_context(&path)?;
out.push((path, parsed));
}
Ok(out)
}
fn discover_artifacts_manifests(dist: &Path) -> Result<Vec<PathBuf>> {
discover_sharded_manifests(dist, "artifacts")
}
fn check_no_unsuffixed_suffixed_collision(dist: &Path, base: &str) -> Result<()> {
let unsuffixed = dist.join(format!("{base}.json"));
if !unsuffixed.is_file() {
return Ok(());
}
let entries = std::fs::read_dir(dist).with_context(|| {
format!(
"publish-only: scanning {} for sharded {} manifests",
dist.display(),
base,
)
})?;
let prefix = format!("{base}-");
let mut sharded: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let name = entry.file_name();
let name = match name.to_str() {
Some(n) => n,
None => continue,
};
if name.ends_with(".tmp") {
continue;
}
if name.starts_with(&prefix) && name.ends_with(".json") {
sharded.push(entry.path());
}
}
if !sharded.is_empty() {
sharded.sort();
let sharded_display = sharded
.iter()
.map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<?>")
.to_string()
})
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"publish-only: both {base}.json AND sharded {base}-*.json ({sharded_display}) \
exist at {dist}. This indicates upload-artifact merged shards' \
un-suffixed {base}.json files over each other before they were \
properly suffixed — the surviving {base}.json is only one shard's view. \
Either delete the un-suffixed {base}.json (if the sharded files are \
authoritative) or delete the sharded files (legacy single-shard mode).",
base = base,
sharded_display = sharded_display,
dist = dist.display(),
);
}
Ok(())
}
fn merge_preserved_contexts(
contexts: &[(PathBuf, PreservedDistContext)],
) -> Result<PreservedDistContext> {
use std::collections::BTreeSet;
let mut merged = PreservedDistContext::default();
let mut targets: BTreeSet<String> = BTreeSet::new();
for (_, c) in contexts {
if merged.version.is_empty() && !c.version.is_empty() {
merged.version = c.version.clone();
}
if merged.commit.is_empty() && !c.commit.is_empty() {
merged.commit = c.commit.clone();
}
for t in &c.targets {
targets.insert(t.clone());
}
for a in &c.artifacts {
merged.artifacts.push(PreservedArtifact {
name: a.name.clone(),
path: a.path.clone(),
sha256: a.sha256.clone(),
size: a.size,
});
}
}
merged.targets = targets.into_iter().collect();
if merged.commit.is_empty() {
anyhow::bail!(
"publish-only: no context manifest carried a `commit` field. Cannot verify the \
preserved bytes match the current release; re-run \
`anodize check determinism --preserve-dist=...` with a producer that \
records the commit SHA."
);
}
for (path, ctx_entry) in contexts {
if !ctx_entry.commit.is_empty() && ctx_entry.commit != merged.commit {
anyhow::bail!(
"publish-only: shard manifest {} records commit {} but the merged set is \
anchored at {}. A multi-shard preserved dist must come from a single \
release attempt; mixing bytes from different commits would publish \
signatures whose determinism-verified state is split.",
path.display(),
short_commit_str(&ctx_entry.commit),
short_commit_str(&merged.commit),
);
}
}
for (path, ctx_entry) in contexts {
if !ctx_entry.version.is_empty() && ctx_entry.version != merged.version {
anyhow::bail!(
"publish-only: shard manifest {} records version {} but the merged set is \
anchored at {}. A multi-shard preserved dist must come from a single \
release attempt; mixing bytes across versions would publish \
signatures whose determinism-verified state is split.",
path.display(),
ctx_entry.version,
merged.version,
);
}
}
Ok(merged)
}
fn load_preserved_context(path: &Path) -> Result<PreservedDistContext> {
if !path.exists() {
anyhow::bail!(
"publish-only: missing {}. Run `anodize check determinism \
--preserve-dist=<dist-dir>` on a green determinism check first, or use \
`anodize publish` (no sign step) if you only need the publisher pass.",
path.display(),
);
}
let bytes =
std::fs::read(path).with_context(|| format!("publish-only: read {}", path.display()))?;
let ctx: PreservedDistContext = serde_json::from_slice(&bytes).with_context(|| {
format!(
"publish-only: parse {} as PreservedDistContext",
path.display()
)
})?;
Ok(ctx)
}
const EPHEMERAL_SIGNATURE_SUFFIXES: &[&str] = &[".sig", ".asc", ".pem"];
fn is_ephemeral_signature_path(path: &str) -> bool {
EPHEMERAL_SIGNATURE_SUFFIXES
.iter()
.any(|suffix| path.ends_with(suffix))
}
fn hash_verify_preserved_dist(ctx: &PreservedDistContext, dist_root: &Path) -> Result<()> {
use std::collections::BTreeMap;
let mut by_path: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for artifact in &ctx.artifacts {
if is_ephemeral_signature_path(&artifact.path) {
continue;
}
by_path
.entry(artifact.path.as_str())
.or_default()
.push(artifact.sha256.as_str());
}
for (path_str, expected_hashes) in &by_path {
let path = dist_root.join(path_str);
let actual_hex = anodizer_core::hashing::sha256_file(&path).with_context(|| {
format!(
"publish-only hash-verify: hashing preserved artifact {}",
path.display(),
)
})?;
let actual = format!("sha256:{actual_hex}");
let expected_normalized: Vec<String> = expected_hashes
.iter()
.map(|h| {
if h.starts_with("sha256:") {
(*h).to_string()
} else {
format!("sha256:{h}")
}
})
.collect();
let matches_any = expected_normalized.iter().any(|e| e == &actual);
if !matches_any {
let mut distinct: Vec<&String> = expected_normalized.iter().collect();
distinct.sort();
distinct.dedup();
let expected_list = distinct
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"publish-only hash-verify: bytes on disk diverge from every shard's recorded \
determinism state for {} (recorded across {} shard(s): [{}], on disk: {}). \
The dist tree was modified between determinism check and publish, OR no \
shard's preserved bytes survived `download-artifact merge-multiple` — \
refusing to ship.",
path.display(),
expected_normalized.len(),
expected_list,
actual,
);
}
}
Ok(())
}
fn cleanup_shard_manifests(dist: &Path, log: &StageLogger) {
let base = "artifacts";
let entries = match std::fs::read_dir(dist) {
Ok(e) => e,
Err(e) => {
log.warn(&format!(
"publish-only: failed to read {} for shard-manifest cleanup: {} \
(a retry may trip the unsuffixed-vs-suffixed collision check)",
dist.display(),
e,
));
return;
}
};
let prefix = format!("{base}-");
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if name_str.starts_with(&prefix) && name_str.ends_with(".json") {
let path = entry.path();
if let Err(e) = std::fs::remove_file(&path) {
log.warn(&format!(
"publish-only: failed to remove shard manifest {}: {} \
(a retry may trip the unsuffixed-vs-suffixed collision check)",
path.display(),
e
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from(map: HashMap<&str, &str>) -> impl Fn(&str) -> Option<String> {
let owned: HashMap<String, String> = map
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k| owned.get(k).cloned()
}
#[test]
fn load_preserved_context_rejects_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let err = load_preserved_context(&tmp.path().join("context.json")).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("publish-only: missing"),
"error should name the publish-only path; got: {msg}"
);
assert!(
msg.contains("--preserve-dist"),
"error should point at the preserve-dist flag; got: {msg}"
);
assert!(
msg.contains("<dist-dir>"),
"error should use the literal <dist-dir> placeholder; got: {msg}"
);
}
#[test]
fn load_preserved_context_parses_minimal_json() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("context.json");
std::fs::write(
&path,
r#"{"artifacts":[{"name":"a.tar.gz","path":"a.tar.gz","sha256":"sha256:abc","size":42}],"targets":["x86_64-unknown-linux-gnu"],"version":"0.1.0","commit":"deadbeefcafe"}"#,
)
.unwrap();
let parsed = load_preserved_context(&path).unwrap();
assert_eq!(parsed.version, "0.1.0");
assert_eq!(parsed.commit, "deadbeefcafe");
assert_eq!(parsed.targets, vec!["x86_64-unknown-linux-gnu"]);
assert_eq!(parsed.artifacts.len(), 1);
assert_eq!(parsed.artifacts[0].name, "a.tar.gz");
}
#[test]
fn load_preserved_context_tolerates_missing_fields() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("context.json");
std::fs::write(&path, r#"{}"#).unwrap();
let parsed = load_preserved_context(&path).unwrap();
assert!(parsed.artifacts.is_empty());
assert!(parsed.targets.is_empty());
assert_eq!(parsed.version, "");
assert_eq!(parsed.commit, "");
}
#[test]
fn preflight_credentials_bails_when_token_missing() {
let err = preflight_credentials(|_| None).unwrap_err();
assert!(
format!("{err}").contains("missing release token"),
"expected missing-token error; got: {err}"
);
}
#[test]
fn preflight_credentials_bails_when_sign_key_missing() {
let env = env_from(HashMap::from([("GITHUB_TOKEN", "x")]));
let err = preflight_credentials(env).unwrap_err();
assert!(
format!("{err}").contains("missing production signing key"),
"expected missing-sign-key error after token set; got: {err}"
);
}
#[test]
fn preflight_credentials_accepts_token_and_cosign_key() {
let env = env_from(HashMap::from([("GITHUB_TOKEN", "x"), ("COSIGN_KEY", "y")]));
preflight_credentials(env).expect("token + cosign should preflight clean");
}
#[test]
fn preflight_credentials_accepts_anodizer_github_token_alias() {
let env = env_from(HashMap::from([
("ANODIZER_GITHUB_TOKEN", "x"),
("GPG_PRIVATE_KEY", "y"),
]));
preflight_credentials(env).expect("anodizer github token + gpg key should preflight clean");
}
#[test]
fn preflight_credentials_rejects_empty_token_value() {
let env = env_from(HashMap::from([("GITHUB_TOKEN", ""), ("COSIGN_KEY", "y")]));
let err = preflight_credentials(env).unwrap_err();
assert!(
format!("{err}").contains("missing release token"),
"empty token must be treated as missing; got: {err}"
);
}
#[test]
fn discover_sharded_manifests_skips_tmp_siblings_uniformly() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context.json.tmp"), "garbage").unwrap();
std::fs::write(tmp.path().join("artifacts.json"), "[]").unwrap();
std::fs::write(tmp.path().join("artifacts.json.tmp"), "garbage").unwrap();
std::fs::write(tmp.path().join("artifacts-linux.json"), "[]").unwrap();
std::fs::write(tmp.path().join("artifacts-linux.json.tmp"), "garbage").unwrap();
let ctx = discover_sharded_manifests(tmp.path(), "context").unwrap();
let names: Vec<String> = ctx
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["context.json"], "tmp siblings must be skipped");
let arts = discover_sharded_manifests(tmp.path(), "artifacts").unwrap();
let names: Vec<String> = arts
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(
names,
vec!["artifacts-linux.json", "artifacts.json"],
"artifacts family must also skip .tmp; got {names:?}"
);
}
#[test]
fn collision_check_errors_when_unsuffixed_and_suffixed_both_present_context() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context-linux.json"), "{}").unwrap();
let err = check_no_unsuffixed_suffixed_collision(tmp.path(), "context").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("context.json") && msg.contains("context-linux.json"),
"error should name both colliding manifests; got: {msg}"
);
assert!(
msg.contains("upload-artifact merged"),
"error should name the symptom hypothesis; got: {msg}"
);
}
#[test]
fn collision_check_errors_when_unsuffixed_and_suffixed_both_present_artifacts() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artifacts.json"), "[]").unwrap();
std::fs::write(tmp.path().join("artifacts-darwin.json"), "[]").unwrap();
let err = check_no_unsuffixed_suffixed_collision(tmp.path(), "artifacts").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("artifacts.json") && msg.contains("artifacts-darwin.json"),
"error should name both colliding manifests; got: {msg}"
);
}
#[test]
fn collision_check_ok_for_unsuffixed_alone() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
check_no_unsuffixed_suffixed_collision(tmp.path(), "context")
.expect("unsuffixed-only must be fine");
}
#[test]
fn collision_check_ok_for_suffixed_only() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context-a.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context-b.json"), "{}").unwrap();
check_no_unsuffixed_suffixed_collision(tmp.path(), "context")
.expect("suffixed-only must be fine");
}
#[test]
fn collision_check_ignores_tmp_sibling_of_suffixed() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context-linux.json.tmp"), "garbage").unwrap();
check_no_unsuffixed_suffixed_collision(tmp.path(), "context")
.expect(".tmp sibling must not trigger collision");
}
fn ctx_entry(version: &str, commit: &str) -> PreservedDistContext {
PreservedDistContext {
artifacts: vec![],
targets: vec![],
version: version.to_string(),
commit: commit.to_string(),
}
}
#[test]
fn merge_preserved_contexts_bails_when_commit_empty_everywhere() {
let contexts = vec![
(PathBuf::from("context-a.json"), ctx_entry("0.1.0", "")),
(PathBuf::from("context-b.json"), ctx_entry("0.1.0", "")),
];
let err = merge_preserved_contexts(&contexts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("no context manifest carried a `commit`"),
"expected commit-missing diagnostic; got: {msg}"
);
}
#[test]
fn merge_preserved_contexts_bails_on_commit_mismatch_across_shards() {
let contexts = vec![
(
PathBuf::from("context-a.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
(
PathBuf::from("context-b.json"),
ctx_entry("0.1.0", "ba5eba11feed"),
),
];
let err = merge_preserved_contexts(&contexts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("records commit") && msg.contains("merged set is"),
"expected per-shard commit-mismatch diagnostic; got: {msg}"
);
assert!(
msg.contains("context-b.json"),
"diagnostic must name the dissenting shard; got: {msg}"
);
}
#[test]
fn merge_preserved_contexts_bails_on_version_mismatch_across_shards() {
let contexts = vec![
(
PathBuf::from("context-a.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
(
PathBuf::from("context-b.json"),
ctx_entry("0.2.0", "deadbeefcafe"),
),
];
let err = merge_preserved_contexts(&contexts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("records version") && msg.contains("merged set is"),
"expected per-shard version-mismatch diagnostic; got: {msg}"
);
assert!(
msg.contains("context-b.json"),
"diagnostic must name the dissenting shard; got: {msg}"
);
}
#[test]
fn merge_preserved_contexts_accepts_consistent_shards() {
let contexts = vec![
(
PathBuf::from("context-a.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
(
PathBuf::from("context-b.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
];
let merged = merge_preserved_contexts(&contexts).expect("consistent shards must merge");
assert_eq!(merged.commit, "deadbeefcafe");
assert_eq!(merged.version, "0.1.0");
}
#[test]
fn merge_preserved_contexts_tolerates_one_shard_with_empty_commit() {
let contexts = vec![
(PathBuf::from("context-a.json"), ctx_entry("0.1.0", "")),
(
PathBuf::from("context-b.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
];
let merged = merge_preserved_contexts(&contexts).expect("mixed-empty shards must merge");
assert_eq!(merged.commit, "deadbeefcafe");
}
#[test]
fn detect_duplicate_paths_in_passes_on_unique_set() {
let paths = [Path::new("a.tar.gz"), Path::new("b.tar.gz")];
crate::commands::helpers::detect_duplicate_paths(paths).expect("unique paths must pass");
}
#[test]
fn detect_duplicate_paths_in_flags_repeated_path() {
let paths = [
Path::new("a.tar.gz"),
Path::new("b.tar.gz"),
Path::new("a.tar.gz"),
];
let err = crate::commands::helpers::detect_duplicate_paths(paths).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("a.tar.gz"),
"error must name the duplicated path; got: {msg}"
);
assert!(
msg.contains("(2×)"),
"error must show the duplicate count; got: {msg}"
);
assert!(
msg.contains("shards overlapped"),
"error must name the matrix-overlap hypothesis; got: {msg}"
);
}
#[test]
fn detect_missing_files_in_passes_when_all_present() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.tar.gz");
std::fs::write(&a, b"x").unwrap();
std::fs::write(tmp.path().join("rel.tar.gz"), b"x").unwrap();
let paths = [a.as_path(), Path::new("rel.tar.gz")];
crate::commands::helpers::detect_missing_files(paths, tmp.path())
.expect("all present must pass");
}
#[test]
fn detect_missing_files_in_errors_on_absent_absolute_path() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist.tar.gz");
let paths = [missing.as_path()];
let err = crate::commands::helpers::detect_missing_files(paths, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("does-not-exist.tar.gz"),
"error must name the missing file; got: {msg}"
);
assert!(
msg.contains("preserved dist is incomplete"),
"error must surface the incomplete-dist hypothesis; got: {msg}"
);
}
#[test]
fn detect_missing_files_in_errors_on_absent_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let paths = [Path::new("rel-missing.tar.gz")];
let err = crate::commands::helpers::detect_missing_files(paths, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("rel-missing.tar.gz"),
"error must name the missing relative file; got: {msg}"
);
}
#[test]
fn detect_missing_files_in_ignores_files_not_in_manifest() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.tar.gz");
std::fs::write(&a, b"x").unwrap();
std::fs::write(tmp.path().join("metadata.json"), b"{}").unwrap();
std::fs::write(tmp.path().join("orphan.tar.gz"), b"x").unwrap();
let paths = [a.as_path()];
crate::commands::helpers::detect_missing_files(paths, tmp.path())
.expect("unreferenced dist files must not trigger the check");
}
const HELLO_WORLD_SHA256: &str =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
#[test]
fn hash_verify_preserved_dist_accepts_matching_bytes() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("hello.txt"), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: "hello.txt".into(),
path: "hello.txt".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 11,
}],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path()).expect("matching bytes must verify clean");
}
#[test]
fn hash_verify_preserved_dist_rejects_mismatched_bytes() {
let tmp = tempfile::tempdir().unwrap();
let rel = "hello.txt";
std::fs::write(tmp.path().join(rel), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: rel.into(),
path: rel.into(),
sha256: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
.into(),
size: 11,
}],
..PreservedDistContext::default()
};
let err = hash_verify_preserved_dist(&ctx, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("diverge"),
"error must surface the divergence wording; got: {msg}"
);
assert!(
msg.contains(rel),
"error must name the offending file; got: {msg}"
);
}
#[test]
fn hash_verify_preserved_dist_skips_ephemeral_signatures() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("foo.tar.gz.sha256.sig"), b"shard-A-bytes").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: "foo.tar.gz.sha256.sig".into(),
path: "foo.tar.gz.sha256.sig".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 13,
}],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path())
.expect("ephemeral .sig paths must skip hash-verify");
}
#[test]
fn hash_verify_preserved_dist_skips_pem_and_asc() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("foo.pem"), b"cert-A").unwrap();
std::fs::write(tmp.path().join("foo.asc"), b"asc-A").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![
PreservedArtifact {
name: "foo.pem".into(),
path: "foo.pem".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 6,
},
PreservedArtifact {
name: "foo.asc".into(),
path: "foo.asc".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 5,
},
],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path())
.expect("ephemeral .pem / .asc paths must skip hash-verify");
}
#[test]
fn hash_verify_preserved_dist_accepts_when_any_shard_matches_disk() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("source.tar.gz"), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.into(),
size: 11,
},
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 11,
},
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:1111111111111111111111111111111111111111111111111111111111111111"
.into(),
size: 11,
},
],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path())
.expect("cross-shard duplicate must verify when any shard's hash matches disk");
}
#[test]
fn hash_verify_preserved_dist_bails_when_no_shard_matches_disk() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("source.tar.gz"), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.into(),
size: 11,
},
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:1111111111111111111111111111111111111111111111111111111111111111"
.into(),
size: 11,
},
],
..PreservedDistContext::default()
};
let err = hash_verify_preserved_dist(&ctx, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("recorded across 2 shard(s)"),
"error must surface the shard count; got: {msg}"
);
assert!(
msg.contains("source.tar.gz"),
"error must name the offending file; got: {msg}"
);
}
#[test]
fn hash_verify_preserved_dist_rejects_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: "absent.tar.gz".into(),
path: "absent.tar.gz".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 11,
}],
..PreservedDistContext::default()
};
let err = hash_verify_preserved_dist(&ctx, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("hashing preserved artifact"),
"error must surface the hash-failure wording; got: {msg}"
);
assert!(
msg.contains("absent.tar.gz"),
"error must name the missing file; got: {msg}"
);
}
#[test]
fn cleanup_shard_manifests_removes_only_artifacts_shards_leaves_context() {
use anodizer_core::log::Verbosity;
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path();
std::fs::write(dist.join("artifacts.json"), b"[]").unwrap();
std::fs::write(dist.join("artifacts-ubuntu-latest.json"), b"[]").unwrap();
std::fs::write(dist.join("artifacts-macos-latest.json"), b"[]").unwrap();
std::fs::write(dist.join("artifacts-windows-x86_64.json"), b"[]").unwrap();
std::fs::write(dist.join("context-ubuntu-latest.json"), b"{}").unwrap();
std::fs::write(dist.join("context-macos-latest.json"), b"{}").unwrap();
let log = StageLogger::new("test", Verbosity::Quiet);
cleanup_shard_manifests(dist, &log);
assert!(dist.join("artifacts.json").is_file());
assert!(!dist.join("artifacts-ubuntu-latest.json").exists());
assert!(!dist.join("artifacts-macos-latest.json").exists());
assert!(!dist.join("artifacts-windows-x86_64.json").exists());
assert!(dist.join("context-ubuntu-latest.json").is_file());
assert!(dist.join("context-macos-latest.json").is_file());
}
#[test]
fn missing_file_check_skips_binary_and_universal_binary_kinds() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::context::{Context, ContextOptions};
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
let kinds = [
ArtifactKind::Binary,
ArtifactKind::UniversalBinary,
ArtifactKind::Archive,
ArtifactKind::Checksum,
];
for (i, k) in kinds.iter().enumerate() {
ctx.artifacts.add(Artifact {
kind: *k,
name: format!("art-{i}"),
path: std::path::PathBuf::from(format!("art-{i}")),
target: None,
crate_name: String::new(),
metadata: Default::default(),
size: None,
});
}
let kept: Vec<ArtifactKind> = ctx
.artifacts
.all()
.iter()
.filter(|a| !matches!(a.kind, ArtifactKind::Binary | ArtifactKind::UniversalBinary))
.map(|a| a.kind)
.collect();
assert_eq!(kept, vec![ArtifactKind::Archive, ArtifactKind::Checksum]);
}
fn write_context_file(dir: &std::path::Path, name: &str) {
let content = r#"{"artifacts":[],"targets":[],"version":"0.0.0","commit":"abc"}"#;
std::fs::write(dir.join(name), content).unwrap();
}
fn layout_test_log() -> StageLogger {
StageLogger::new("layout-test", anodizer_core::log::Verbosity::Quiet)
}
#[test]
fn detect_layout_flat_single_context() {
let tmp = tempfile::tempdir().unwrap();
write_context_file(tmp.path(), "context.json");
let layout = super::detect_dist_layout(tmp.path(), &layout_test_log()).unwrap();
assert!(
matches!(layout, super::DistLayout::Flat),
"expected Flat, got {layout:?}"
);
}
#[test]
fn detect_layout_flat_sharded_context() {
let tmp = tempfile::tempdir().unwrap();
write_context_file(tmp.path(), "context-linux.json");
write_context_file(tmp.path(), "context-macos.json");
let layout = super::detect_dist_layout(tmp.path(), &layout_test_log()).unwrap();
assert!(
matches!(layout, super::DistLayout::Flat),
"expected Flat, got {layout:?}"
);
}
#[test]
fn detect_layout_per_crate_two_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("core");
let b = tmp.path().join("cli");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
write_context_file(&a, "context.json");
write_context_file(&b, "context.json");
let layout = super::detect_dist_layout(tmp.path(), &layout_test_log()).unwrap();
match layout {
super::DistLayout::PerCrate(names) => {
let mut sorted = names.clone();
sorted.sort();
assert_eq!(sorted, vec!["cli", "core"]);
}
other => panic!("expected PerCrate, got {other:?}"),
}
}
#[test]
fn detect_layout_ambiguous_flat_and_per_crate() {
let tmp = tempfile::tempdir().unwrap();
write_context_file(tmp.path(), "context.json");
let sub = tmp.path().join("core");
std::fs::create_dir_all(&sub).unwrap();
write_context_file(&sub, "context.json");
let layout = super::detect_dist_layout(tmp.path(), &layout_test_log()).unwrap();
assert!(
matches!(layout, super::DistLayout::Ambiguous { .. }),
"expected Ambiguous, got {layout:?}"
);
}
#[test]
fn detect_layout_empty_dist_returns_flat() {
let tmp = tempfile::tempdir().unwrap();
let layout = super::detect_dist_layout(tmp.path(), &layout_test_log()).unwrap();
assert!(
matches!(layout, super::DistLayout::Flat),
"empty dist must return Flat, got {layout:?}"
);
}
#[test]
fn detect_layout_subdir_without_context_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
write_context_file(tmp.path(), "context-linux.json");
let sub = tmp.path().join("random-dir");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("artifact.tar.gz"), b"bytes").unwrap();
let layout = super::detect_dist_layout(tmp.path(), &layout_test_log()).unwrap();
assert!(
matches!(layout, super::DistLayout::Flat),
"subdir without context.json must not count as per-crate, got {layout:?}"
);
}
#[test]
fn merge_workspace_skip_appends_new_entries() {
let mut into: Vec<String> = vec![];
super::merge_workspace_skip(&mut into, &["announce".to_string(), "publish".to_string()]);
assert_eq!(into, vec!["announce", "publish"]);
}
#[test]
fn merge_workspace_skip_dedupes_existing_cli_entries() {
let mut into: Vec<String> = vec!["announce".to_string()];
super::merge_workspace_skip(&mut into, &["announce".to_string(), "blob".to_string()]);
assert_eq!(into, vec!["announce", "blob"]);
}
#[test]
fn merge_workspace_skip_empty_ws_is_noop() {
let mut into: Vec<String> = vec!["snapcraft-publish".to_string()];
super::merge_workspace_skip(&mut into, &[]);
assert_eq!(into, vec!["snapcraft-publish"]);
}
#[test]
fn merge_workspace_skip_propagates_cfgd_core_announce_skip() {
let mut into: Vec<String> = vec![];
super::merge_workspace_skip(&mut into, &["announce".to_string()]);
assert!(
into.iter().any(|s| s == "announce"),
"workspace-level announce skip must propagate; got {:?}",
into
);
}
#[test]
fn run_per_crate_restores_ctx_config_dist_on_error() {
use anodizer_core::config::Config;
use anodizer_core::context::{Context, ContextOptions};
let mut config = Config::default();
let original_dist = std::path::PathBuf::from("/tmp/anodize-publish-only-restore-test/dist");
config.dist = original_dist.clone();
let mut ctx = Context::new(config.clone(), ContextOptions::default());
let dist_base = std::path::PathBuf::from(
"/tmp/anodize-publish-only-restore-test/nonexistent-dist-base",
);
let log = anodizer_core::log::StageLogger::new(
"publish-only-restore-test",
anodizer_core::log::Verbosity::Quiet,
);
let opts = RunOpts {
dry_run: true,
no_preflight: true,
silent_meta: false,
};
let result = run_per_crate(
&mut ctx,
&config,
&log,
opts,
dist_base,
vec!["cfgd".to_string()],
);
assert!(
result.is_err(),
"iteration must fail when dist_base is absent — fixture precondition"
);
assert_eq!(
ctx.config.dist, original_dist,
"ctx.config.dist must be restored after the iteration (Ok or Err) \
so the per-iteration override never leaks into the caller's context"
);
}
#[test]
fn per_crate_overlay_guard_restores_on_panic() {
use anodizer_core::config::Config;
use anodizer_core::context::{Context, ContextOptions};
use std::panic::{AssertUnwindSafe, catch_unwind};
let mut config = Config::default();
let original_dist = std::path::PathBuf::from("/tmp/per-crate-guard-panic/dist");
config.dist = original_dist.clone();
let mut ctx = Context::new(config, ContextOptions::default());
let original_selected = vec!["root-crate".to_string()];
let original_skip = vec!["root-skip".to_string()];
ctx.options.selected_crates = original_selected.clone();
ctx.options.skip_stages = original_skip.clone();
let result = catch_unwind(AssertUnwindSafe(|| {
let mut guard = PerCrateOverlayGuard::capture(&mut ctx);
let inner = guard.ctx_mut();
inner.config.dist = std::path::PathBuf::from("/scratch/mid-iteration");
inner.options.selected_crates = vec!["mid-iter-crate".to_string()];
inner.options.skip_stages = vec!["mid-iter-skip".to_string()];
panic!("simulated mid-iteration panic");
}));
assert!(
result.is_err(),
"fixture must actually panic — otherwise the guard's restore would also \
run via the happy path and the test would pass trivially"
);
assert_eq!(
ctx.config.dist, original_dist,
"Drop must restore ctx.config.dist on panic"
);
assert_eq!(
ctx.options.selected_crates, original_selected,
"Drop must restore ctx.options.selected_crates on panic"
);
assert_eq!(
ctx.options.skip_stages, original_skip,
"Drop must restore ctx.options.skip_stages on panic"
);
}
#[test]
fn per_crate_overlay_does_not_leak_across_workspaces() {
use anodizer_core::config::{
ChangelogConfig, Config, CrateConfig, HookEntry, HooksConfig, WorkspaceConfig,
};
use anodizer_core::context::{Context, ContextOptions};
use anodizer_core::signing::SignConfig;
fn ws(name: &str, set_overlay: bool) -> WorkspaceConfig {
WorkspaceConfig {
name: name.to_string(),
crates: vec![CrateConfig {
name: name.to_string(),
..CrateConfig::default()
}],
changelog: set_overlay.then(|| ChangelogConfig {
format: Some(format!("{name}-format")),
..ChangelogConfig::default()
}),
signs: if set_overlay {
vec![SignConfig {
id: Some(format!("{name}-sign")),
..SignConfig::default()
}]
} else {
Vec::new()
},
binary_signs: if set_overlay {
vec![SignConfig {
id: Some(format!("{name}-binary-sign")),
..SignConfig::default()
}]
} else {
Vec::new()
},
before: set_overlay.then(|| HooksConfig {
hooks: Some(vec![HookEntry::Simple(format!("{name}-before"))]),
post: None,
}),
after: set_overlay.then(|| HooksConfig {
hooks: Some(vec![HookEntry::Simple(format!("{name}-after"))]),
post: None,
}),
env: set_overlay.then(|| vec![format!("{name}_KEY=1")]),
..WorkspaceConfig::default()
}
}
let mut ctx = Context::new(Config::default(), ContextOptions::default());
let workspace_a = ws("alpha", true);
let workspace_b = ws("beta", false);
let mut guard = PerCrateOverlayGuard::capture(&mut ctx);
guard.reset_overlay_fields();
crate::commands::helpers::apply_workspace_overlay(
&mut guard.ctx_mut().config,
&workspace_a,
);
{
let cfg = &guard.ctx_mut().config;
assert_eq!(
cfg.changelog.as_ref().and_then(|c| c.format.as_deref()),
Some("alpha-format")
);
assert_eq!(cfg.signs.len(), 1);
assert_eq!(cfg.binary_signs.len(), 1);
assert_eq!(
cfg.before
.as_ref()
.and_then(|h| h.hooks.as_ref())
.map(|v| v.as_slice()),
Some([HookEntry::Simple("alpha-before".to_string())].as_slice())
);
assert_eq!(
cfg.after
.as_ref()
.and_then(|h| h.hooks.as_ref())
.map(|v| v.as_slice()),
Some([HookEntry::Simple("alpha-after".to_string())].as_slice())
);
assert_eq!(
cfg.env.as_deref(),
Some(["alpha_KEY=1".to_string()].as_slice())
);
}
guard.reset_overlay_fields();
crate::commands::helpers::apply_workspace_overlay(
&mut guard.ctx_mut().config,
&workspace_b,
);
{
let cfg = &guard.ctx_mut().config;
assert!(
cfg.changelog.is_none(),
"workspace B must not inherit A's changelog"
);
assert!(
cfg.signs.is_empty(),
"workspace B must not inherit A's signs"
);
assert!(
cfg.binary_signs.is_empty(),
"workspace B must not inherit A's binary_signs"
);
assert!(
cfg.before.is_none(),
"workspace B must not inherit A's before hooks"
);
assert!(
cfg.after.is_none(),
"workspace B must not inherit A's after hooks"
);
assert!(
cfg.env.as_ref().map(|e| e.is_empty()).unwrap_or(true),
"env must not accumulate A's entries into B's iteration: {:?}",
cfg.env
);
}
drop(guard);
assert!(ctx.config.changelog.is_none());
assert!(ctx.config.signs.is_empty());
assert!(ctx.config.binary_signs.is_empty());
assert!(ctx.config.before.is_none());
assert!(ctx.config.after.is_none());
assert!(
ctx.config
.env
.as_ref()
.map(|e| e.is_empty())
.unwrap_or(true)
);
}
mod per_crate_tag {
use super::*;
use anodizer_core::config::{Config, CrateConfig, WorkspaceConfig};
use anodizer_core::context::{Context, ContextOptions};
use anodizer_core::log::{StageLogger, Verbosity};
use serial_test::serial;
fn quiet_log() -> StageLogger {
StageLogger::new("per-crate-tag-test", Verbosity::Quiet)
}
fn with_hermetic_git_cwd(body: impl FnOnce()) {
let tmp = tempfile::tempdir().unwrap();
assert!(
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.status()
.expect("spawn git init")
.success(),
"git init must succeed for the hermetic tag-test repo",
);
let orig = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
std::env::set_current_dir(orig).unwrap();
if let Err(payload) = result {
std::panic::resume_unwind(payload);
}
}
fn crate_cfg(name: &str, tag_template: &str) -> CrateConfig {
CrateConfig {
name: name.to_string(),
tag_template: tag_template.to_string(),
..CrateConfig::default()
}
}
fn config_with_crates(crates: Vec<CrateConfig>) -> Config {
Config {
crates,
..Config::default()
}
}
#[test]
#[serial]
fn restores_per_crate_tag_from_tag_template() {
with_hermetic_git_cwd(|| {
for (crate_name, tag_template, expect_tag) in [
("cfgd", "v{{ Version }}", "v0.4.0"),
("cfgd-core", "core-v{{ Version }}", "core-v0.4.0"),
(
"cfgd-operator",
"operator-v{{ Version }}",
"operator-v0.4.0",
),
] {
let config = config_with_crates(vec![crate_cfg(crate_name, tag_template)]);
let mut ctx = Context::new(config.clone(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Tag", "core-v0.4.0");
apply_per_crate_tag(&mut ctx, &config, crate_name, &quiet_log());
assert_eq!(
ctx.template_vars().get("Tag").map(String::as_str),
Some(expect_tag),
"crate '{crate_name}' must carry its own tag, not the global HEAD tag",
);
}
});
}
fn write_preserved_version(
base: &Path,
crate_name: &str,
version: &str,
) -> std::path::PathBuf {
let crate_dist = base.join(crate_name);
std::fs::create_dir_all(&crate_dist).unwrap();
std::fs::write(
crate_dist.join("context.json"),
format!(r#"{{"version":"{version}","commit":"deadbeefcafe"}}"#),
)
.unwrap();
crate_dist
}
#[test]
#[serial]
fn independent_version_workspace_renders_per_crate_version() {
with_hermetic_git_cwd(|| {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let cases = [
("cfgd", "v{{ Version }}", "0.4.0", "v0.4.0"),
("cfgd-core", "core-v{{ Version }}", "0.5.1", "core-v0.5.1"),
];
for (crate_name, tag_template, preserved_version, expect_tag) in cases {
let crate_dist = write_preserved_version(&dist, crate_name, preserved_version);
let config = config_with_crates(vec![crate_cfg(crate_name, tag_template)]);
let mut ctx = Context::new(config.clone(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Tag", "v0.4.0");
apply_per_crate_version(&mut ctx, &crate_dist, crate_name, &quiet_log());
assert_eq!(
ctx.template_vars().get("Version").map(String::as_str),
Some(preserved_version),
"crate '{crate_name}' must carry its own preserved Version",
);
apply_per_crate_tag(&mut ctx, &config, crate_name, &quiet_log());
assert_eq!(
ctx.template_vars().get("Tag").map(String::as_str),
Some(expect_tag),
"crate '{crate_name}' tag must render against its own preserved version",
);
}
});
}
fn write_preserved_no_version(base: &Path, crate_name: &str) -> std::path::PathBuf {
let crate_dist = base.join(crate_name);
std::fs::create_dir_all(&crate_dist).unwrap();
std::fs::write(
crate_dist.join("context.json"),
r#"{"version":"","commit":"deadbeefcafe"}"#,
)
.unwrap();
crate_dist
}
#[test]
fn per_iteration_reset_prevents_version_bleed_when_next_crate_lacks_version() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let crate1_dist = write_preserved_version(&dist, "cfgd-core", "0.5.1");
let crate2_dist = write_preserved_no_version(&dist, "cfgd");
let mut ctx = Context::new(Config::default(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Major", "0");
ctx.template_vars_mut().set("Minor", "4");
ctx.template_vars_mut().set("Patch", "0");
let mut guard = PerCrateOverlayGuard::capture(&mut ctx);
guard.reset_version_vars();
apply_per_crate_version(guard.ctx_mut(), &crate1_dist, "cfgd-core", &quiet_log());
assert_eq!(
guard
.ctx_mut()
.template_vars()
.get("Version")
.map(String::as_str),
Some("0.5.1"),
"crate 1 must re-anchor to its own preserved version",
);
guard.reset_version_vars();
apply_per_crate_version(guard.ctx_mut(), &crate2_dist, "cfgd", &quiet_log());
let vars = guard.ctx_mut();
assert_eq!(
vars.template_vars().get("Version").map(String::as_str),
Some("0.4.0"),
"crate 2 (no preserved version) must fall back to the pre-loop \
baseline, NOT inherit crate 1's re-anchored version",
);
assert_eq!(
vars.template_vars().get("Major").map(String::as_str),
Some("0"),
"derived Major must also rewind to baseline, not crate 1's",
);
}
#[test]
fn apply_per_crate_version_populates_derived_vars() {
let tmp = tempfile::tempdir().unwrap();
let crate_dist = write_preserved_version(tmp.path(), "cfgd", "1.2.3-rc.1+build.7");
let mut ctx = Context::new(Config::default(), ContextOptions::default());
apply_per_crate_version(&mut ctx, &crate_dist, "cfgd", &quiet_log());
let v = ctx.template_vars();
assert_eq!(
v.get("Version").map(String::as_str),
Some("1.2.3-rc.1+build.7")
);
assert_eq!(v.get("RawVersion").map(String::as_str), Some("1.2.3"));
assert_eq!(v.get("Major").map(String::as_str), Some("1"));
assert_eq!(v.get("Minor").map(String::as_str), Some("2"));
assert_eq!(v.get("Patch").map(String::as_str), Some("3"));
assert_eq!(v.get("Prerelease").map(String::as_str), Some("rc.1"));
assert_eq!(v.get("BuildMetadata").map(String::as_str), Some("build.7"));
}
#[test]
fn apply_per_crate_version_missing_manifest_leaves_version() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = Context::new(Config::default(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "9.9.9");
apply_per_crate_version(
&mut ctx,
&tmp.path().join("absent-crate"),
"absent-crate",
&quiet_log(),
);
assert_eq!(
ctx.template_vars().get("Version").map(String::as_str),
Some("9.9.9"),
"a missing preserved manifest must not clobber the upstream Version",
);
}
#[test]
fn overlay_guard_restores_version_vars() {
let mut ctx = Context::new(Config::default(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Major", "0");
{
let mut guard = PerCrateOverlayGuard::capture(&mut ctx);
let inner = guard.ctx_mut();
inner.template_vars_mut().set("Version", "0.5.1");
inner.template_vars_mut().set("Major", "9");
}
assert_eq!(
ctx.template_vars().get("Version").map(String::as_str),
Some("0.4.0"),
"Drop must restore the caller's Version",
);
assert_eq!(
ctx.template_vars().get("Major").map(String::as_str),
Some("0"),
"Drop must restore the caller's Major",
);
}
#[test]
#[serial]
fn finds_tag_template_in_workspace_fallback() {
with_hermetic_git_cwd(|| {
let config = Config {
workspaces: Some(vec![WorkspaceConfig {
name: "cfgd".to_string(),
crates: vec![crate_cfg("cfgd", "v{{ Version }}")],
..WorkspaceConfig::default()
}]),
..Config::default()
};
let mut ctx = Context::new(config.clone(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Tag", "core-v0.4.0");
apply_per_crate_tag(&mut ctx, &config, "cfgd", &quiet_log());
assert_eq!(
ctx.template_vars().get("Tag").map(String::as_str),
Some("v0.4.0"),
"workspace-list fallback must resolve the crate's tag_template",
);
});
}
#[test]
fn missing_tag_template_leaves_tag_untouched() {
let config = config_with_crates(vec![crate_cfg("known", "v{{ Version }}")]);
let mut ctx = Context::new(config.clone(), ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Tag", "v0.4.0");
apply_per_crate_tag(&mut ctx, &config, "unknown-crate", &quiet_log());
assert_eq!(
ctx.template_vars().get("Tag").map(String::as_str),
Some("v0.4.0"),
"an unmatched crate must not clobber the existing Tag",
);
}
#[test]
fn overlay_guard_restores_tag_and_previous_tag() {
let config = Config {
dist: std::path::PathBuf::from("/tmp/per-crate-guard-tag/dist"),
..Config::default()
};
let mut ctx = Context::new(config, ContextOptions::default());
ctx.template_vars_mut().set("Tag", "v0.4.0");
ctx.template_vars_mut().set("PreviousTag", "v0.3.0");
{
let mut guard = PerCrateOverlayGuard::capture(&mut ctx);
let inner = guard.ctx_mut();
inner.template_vars_mut().set("Tag", "core-v0.4.0");
inner.template_vars_mut().set("PreviousTag", "core-v0.3.0");
}
assert_eq!(
ctx.template_vars().get("Tag").map(String::as_str),
Some("v0.4.0"),
"Drop must restore the caller's Tag",
);
assert_eq!(
ctx.template_vars().get("PreviousTag").map(String::as_str),
Some("v0.3.0"),
"Drop must restore the caller's PreviousTag",
);
}
#[test]
fn write_metadata_json_materializes_per_crate_metadata() {
let tmp = tempfile::tempdir().unwrap();
let flat_dist = tmp.path().join("dist");
let crate_dist = flat_dist.join("cfgd-core");
let config = Config {
project_name: "cfgd".to_string(),
dist: flat_dist.clone(),
crates: vec![crate_cfg("cfgd-core", "core-v{{ Version }}")],
..Config::default()
};
let ctx_config = Config {
dist: crate_dist.clone(),
..config.clone()
};
let mut ctx = Context::new(ctx_config, ContextOptions::default());
ctx.template_vars_mut().set("Version", "0.4.0");
ctx.template_vars_mut().set("Tag", "core-v0.4.0");
ctx.template_vars_mut().set("FullCommit", "deadbeef");
let path =
crate::commands::helpers::write_metadata_json(&ctx, &config, &quiet_log()).unwrap();
assert_eq!(
path,
crate_dist.join("metadata.json"),
"metadata.json must land under ctx.config.dist (per-crate subdir)",
);
assert!(
path.exists(),
"metadata.json must exist for the release upload"
);
assert!(
!flat_dist.join("metadata.json").exists(),
"metadata.json must NOT land at the flat root (where the release \
stage never looks in per-crate mode)",
);
let body = std::fs::read_to_string(&path).unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
json["tag"], "core-v0.4.0",
"metadata must carry the per-crate tag"
);
assert_eq!(json["version"], "0.4.0");
assert_eq!(json["project_name"], "cfgd");
}
}
fn subdir_test_log() -> StageLogger {
StageLogger::new("subdir-test", anodizer_core::log::Verbosity::Quiet)
}
#[test]
fn crate_subdir_has_manifest_detects_context_json() {
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("cfgd");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("context.json"), "{}").unwrap();
assert!(
crate_subdir_has_manifest(tmp.path(), "cfgd", &subdir_test_log()),
"a subdir with context.json must be recognized",
);
}
#[test]
fn crate_subdir_has_manifest_detects_sharded_context() {
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("cfgd");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("context-linux.json"), "{}").unwrap();
assert!(
crate_subdir_has_manifest(tmp.path(), "cfgd", &subdir_test_log()),
"a subdir with a sharded context-<shard>.json must be recognized",
);
}
#[test]
fn crate_subdir_has_manifest_false_for_flat_layout() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
assert!(
!crate_subdir_has_manifest(tmp.path(), "cfgd", &subdir_test_log()),
"absence of dist/<crate>/ must fall back to flat (returns false)",
);
}
}