use clap::{Parser, Subcommand};
use ic_build::cargo::{
environment::{
ConfigurationLimits, configuration_directories, is_build_variable, is_compiler_override,
observe_configurations,
},
lock_projection::{WorkspaceVersionProjection, verify_workspace_release_projection},
release_metadata::{
DependencyVersionProjection, ManifestVersion, render_group_manifest, render_workspace_lock,
},
};
use ic_host_artifacts::artifact::Sha256Digest;
use ic_host_fs::durable::{PublicationMode, WriteOptions};
use ic_host_fs::read::hash_file;
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
env,
error::Error,
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
mod groups;
use groups::GROUP_POLICY;
mod artifact_paths;
use artifact_paths::ArtifactPaths;
mod artifacts;
mod metadata;
mod packages;
mod release;
mod source;
use source::SurfaceState;
mod wasm;
use packages::package_manifest;
#[cfg(test)]
use packages::packages;
mod processes;
use processes::{Execute, Processes};
mod publication;
mod qualification;
use qualification::LockState;
mod evidence;
mod preparation;
use ArchiveLocation::{Retained, Working};
use ReceiptStage::{Final, Prepared};
use evidence::{ArchiveLocation, PackageEvidence, ReceiptStage, ValidationEvidence};
#[cfg(test)]
mod tests;
type Result<T> = std::result::Result<T, Box<dyn Error>>;
const CONFIGURATION_LIMITS: ConfigurationLimits = ConfigurationLimits {
file_bytes: 1024 * 1024,
total_bytes: 4 * 1024 * 1024,
files: 64,
depth: 16,
};
#[derive(Debug, Parser)]
#[command(
name = "repo-tool",
about = "IC Persistence release and qualification tooling"
)]
struct Arguments {
#[command(subcommand)]
command: ToolCommand,
}
#[derive(Debug, Subcommand)]
enum ToolCommand {
Version,
Msrv,
Toolchain,
Target,
EnsureClean,
WasmSize,
ReleasePreflight,
ReleaseVerify,
ReleasePrepareVersion,
ReleasePreparedCheck,
ReleaseFiles,
ReleaseCommitCheck,
ReleaseCommittedCheck,
ReleaseTaggedCheck,
ReleasePushCheck,
QualifyRelease,
Publish {
#[arg(long)]
dry_run: bool,
},
}
#[cfg(test)]
const PACKAGE_README: &str = "crates/ic-memory/README.md";
const CHANGELOG_FINALIZER: &str = "scripts/ci/finalize-release-changelog.awk";
const RELEASE_INPUT_ADMISSION: &str = "scripts/ci/validate-release-inputs.pl";
const TAG_CHECKER: &str = "scripts/ci/check-release-tag.sh";
const REPLACE_FILE_OPTIONS: WriteOptions = WriteOptions {
mode: PublicationMode::Replace,
permissions: 0o666,
};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
struct ReleaseSelection {
group: String,
kind: String,
previous: String,
source: String,
version: String,
date: String,
remote: String,
branch: String,
}
#[derive(Debug, Eq, PartialEq)]
struct InspectedRelease {
commit: String,
selection: ReleaseSelection,
}
struct ReleaseSurface {
original: String,
expected: String,
}
struct Repository<E> {
root: PathBuf,
group: String,
exec: E,
}
fn require(condition: bool, message: &str) -> Result<()> {
if condition {
Ok(())
} else {
Err(message.into())
}
}
impl ReleaseSelection {
fn from_env<E: Execute>(repo: &Repository<E>) -> Result<Self> {
repo.release_group(None)?;
let selection = Self {
group: repo.group.clone(),
kind: env::var("RELEASE_KIND")?,
previous: env::var("RELEASE_PREVIOUS")?,
version: env::var("RELEASE_VERSION")?,
date: env::var("RELEASE_DATE")?,
source: env::var("RELEASE_SOURCE")?,
remote: env::var("RELEASE_REMOTE")?,
branch: env::var("RELEASE_BRANCH")?,
};
require(
matches!(selection.kind.as_str(), "patch" | "minor" | "major"),
"invalid release kind",
)?;
repo.version_parts(&selection.previous)?;
repo.version_parts(&selection.version)?;
repo.validate_date(&selection.date)?;
require(
valid_commit_identity(&selection.source),
"invalid source commit identity",
)?;
require(
!selection.remote.is_empty()
&& selection
.remote
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"invalid selected remote",
)?;
require(
!selection.branch.is_empty()
&& !selection.branch.bytes().any(|byte| byte.is_ascii_control()),
"invalid selected branch",
)?;
Ok(selection)
}
}
fn valid_commit_identity(commit: &str) -> bool {
matches!(commit.len(), 40 | 64)
&& commit
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
fn release_commit_from_env() -> Result<String> {
let commit = env::var("RELEASE_COMMIT")?;
require(
valid_commit_identity(&commit),
"invalid selected release commit",
)?;
Ok(commit)
}
fn toml_string(text: &str, section: &str, field: &str) -> Result<String> {
let value: toml::Value = toml::from_str(text)?;
section
.split('.')
.try_fold(&value, |value, key| value.get(key))
.and_then(|value| value.get(field))
.and_then(toml::Value::as_str)
.map(str::to_owned)
.ok_or_else(|| format!("missing {section}.{field}").into())
}
impl<E: Execute> Repository<E> {
fn run(&self, program: &str, args: &[&str]) -> Result<()> {
self.exec.execute(&self.root, program, args)
}
fn output(&self, program: &str, args: &[&str]) -> Result<String> {
self.exec.capture(&self.root, program, args)
}
fn git(&self, args: &[&str]) -> Result<String> {
Ok(self.output("git", args)?.trim().to_owned())
}
fn version(&self, revision: Option<&str>) -> Result<String> {
let group = self.release_group(revision)?;
let text = self.member_release_manifest(revision, &group.primary)?;
let version = toml_string(&text, "package", "version")?;
self.version_parts(&version)?;
Ok(version)
}
fn toolchain(&self) -> Result<String> {
toml_string(
&self.text("rust-toolchain.toml", None)?,
"toolchain",
"channel",
)
}
fn msrv(&self) -> Result<String> {
toml_string(
&self.text("Cargo.toml", None)?,
"workspace.package",
"rust-version",
)
}
fn identities(&self) -> Result<[String; 3]> {
Ok([
self.output("cargo", &[&format!("+{}", self.toolchain()?), "--version"])?,
self.output("rustc", &[&format!("+{}", self.toolchain()?), "-Vv"])?,
self.output("rustc", &[&format!("+{}", self.msrv()?), "-Vv"])?,
])
}
}
#[expect(
clippy::redundant_pub_crate,
reason = "only the binary entrypoint calls this module"
)]
pub(super) fn main() -> Result<()> {
let arguments = Arguments::parse();
let repo = Repository {
root: env::current_dir()?,
group: env::var("RELEASE_GROUP").unwrap_or_default(),
exec: Processes,
};
match arguments.command {
ToolCommand::Version => println!("{}", repo.version(None)?),
ToolCommand::Msrv => println!("{}", repo.msrv()?),
ToolCommand::Toolchain => println!("{}", repo.toolchain()?),
ToolCommand::Target => println!("{}", repo.target()?.display()),
ToolCommand::EnsureClean => repo.clean()?,
ToolCommand::WasmSize => repo.wasm_size()?,
ToolCommand::ReleasePreflight => repo.preflight(&ReleaseSelection::from_env(&repo)?)?,
ToolCommand::ReleaseVerify => repo.verify(&ReleaseSelection::from_env(&repo)?)?,
ToolCommand::ReleasePrepareVersion => {
repo.prepare_selected(&ReleaseSelection::from_env(&repo)?)?;
}
ToolCommand::ReleasePreparedCheck => {
repo.prepared(&ReleaseSelection::from_env(&repo)?)?;
}
ToolCommand::ReleaseFiles => {
repo.surfaces(&ReleaseSelection::from_env(&repo)?)?;
for path in repo.release_files(None)? {
print!("{path}\0");
}
}
ToolCommand::ReleaseCommitCheck => {
repo.commit_check(&ReleaseSelection::from_env(&repo)?)?;
}
ToolCommand::ReleaseCommittedCheck => {
repo.selected_committed_check(
&ReleaseSelection::from_env(&repo)?,
&release_commit_from_env()?,
)?;
}
ToolCommand::ReleaseTaggedCheck => {
repo.tagged_check(
&ReleaseSelection::from_env(&repo)?,
&release_commit_from_env()?,
)?;
}
ToolCommand::ReleasePushCheck => {
repo.push_check(
&ReleaseSelection::from_env(&repo)?,
&release_commit_from_env()?,
)?;
}
ToolCommand::QualifyRelease => repo.qualify_release()?,
ToolCommand::Publish { dry_run } => {
let flag = match env::var("PUBLISH_DRY_RUN") {
Ok(flag) => flag,
Err(env::VarError::NotPresent) => "0".to_owned(),
Err(error) => return Err(error.into()),
};
require(flag == "0" || flag == "1", "PUBLISH_DRY_RUN must be 0 or 1")?;
repo.publish(dry_run || flag == "1")?;
}
}
Ok(())
}