Publish based on release plan (#3542)

* Publish and tag based on release plan

* Update wording

* Update xtask/src/commands/release/execute_plan.rs

Co-authored-by: Juraj Sadel <jurajsadel@gmail.com>

---------

Co-authored-by: Juraj Sadel <jurajsadel@gmail.com>
This commit is contained in:
Dániel Buga 2025-05-26 13:37:38 +02:00 committed by GitHub
parent 0f4b29d49d
commit 692b7dddc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 235 additions and 60 deletions

View File

@ -6,6 +6,8 @@ pub mod execute_plan;
#[cfg(feature = "release")]
pub mod plan;
pub mod publish;
#[cfg(feature = "release")]
pub mod publish_plan;
pub mod semver_check;
pub mod tag_releases;
@ -15,6 +17,8 @@ pub use execute_plan::*;
#[cfg(feature = "release")]
pub use plan::*;
pub use publish::*;
#[cfg(feature = "release")]
pub use publish_plan::*;
pub use semver_check::*;
pub use tag_releases::*;
@ -35,6 +39,14 @@ pub enum Release {
/// and opens a pull request with the changes.
#[cfg(feature = "release")]
ExecutePlan(ApplyPlanArgs),
/// Attempt to publish the specified package.
///
/// This command will double check based on the release plan that the
/// command is called on the right branch, and that the crate(s) to be
/// released have the right version. After publishing, the command tags
/// the release and pushes the tags.
#[cfg(feature = "release")]
PublishPlan(PublishPlanArgs),
/// Bump the version of the specified package(s).
///
/// This command will, for each specified package:

View File

@ -6,7 +6,7 @@ use clap::Args;
use crate::{
cargo::CargoToml,
commands::{release::plan::Plan, update_package},
git::current_branch,
git::{current_branch, ensure_workspace_clean, get_remote_name_for},
};
#[derive(Debug, Args)]
@ -23,17 +23,8 @@ pub fn execute_plan(workspace: &Path, args: ApplyPlanArgs) -> Result<()> {
let plan_path = workspace.join("release_plan.jsonc");
let plan_path = crate::windows_safe_path(&plan_path);
let plan_source = std::fs::read_to_string(&plan_path)
.with_context(|| format!("Failed to read release plan from {}. Run `cargo xrelease plan` to generate a release plan.", plan_path.display()))?;
if plan_source.lines().any(|line| line.starts_with("//")) {
bail!(
"The release plan has not been finalized. Please open the plan and follow the instructions in it."
);
}
let mut plan = serde_json::from_str::<Plan>(&plan_source)
.with_context(|| format!("Failed to parse release plan from {}", plan_path.display()))?;
let mut plan = Plan::from_path(&plan_path)
.with_context(|| format!("Failed to read release plan from {}", plan_path.display()))?;
ensure!(
current_branch()? == plan.base,
@ -95,24 +86,6 @@ pub fn execute_plan(workspace: &Path, args: ApplyPlanArgs) -> Result<()> {
Ok(())
}
fn ensure_workspace_clean(workspace: &Path) -> Result<()> {
std::env::set_current_dir(workspace)
.with_context(|| format!("Failed to change directory to {}", workspace.display()))?;
let status = Command::new("git")
.arg("status")
.arg("--porcelain")
.output()
.context("Failed to check git status")?;
ensure!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"The workspace is not clean. Please commit or stash your changes before running this command."
);
Ok(())
}
fn make_git_changes(dry_run: bool, release_plan_str: &str, release_plan: &Plan) -> Result<()> {
// Find an available branch name
let branch_name = format!(
@ -198,9 +171,16 @@ The release plan used for this release:
{release_plan_str}
```
Please review the changes and merge them into the main branch.
Once merged, the packages will be ready to be published and tagged.
"#
Please review the changes and merge them into the `{base_branch}` branch.
After merging, please make sure you have this release plan in the repo root,
then run the following command on the `{base_branch}` branch to tag and publish the packages:
```
cargo xrelease publish-plan --no-dry-run
```
"#,
base_branch = release_plan.base,
);
if release_plan.base != "main" {
@ -230,36 +210,11 @@ Once merged, the packages will be ready to be published and tagged.
println!("{open_pr_url}");
}
println!("Once you create and merge the pull request, check out current main.");
println!("Make sure you have the release_plan.jsonc file in the root of the workspace.");
// TODO: uncomment this once we have the `publish` command updated
// println!("Next, run `cargo xrelease publish` to tag the release and publish
// the packages.");
println!("Create the release PR and follow the next steps laid out there.");
Ok(())
}
fn get_remote_name_for(repo: &str) -> Result<String> {
let remotes = Command::new("git")
.arg("remote")
.arg("-v")
.output()
.context("Failed to get remote URL")?;
let remotes = String::from_utf8_lossy(&remotes.stdout);
for line in remotes.lines() {
if line.contains(repo) {
let parts: Vec<_> = line.split_whitespace().collect();
if parts.len() >= 2 {
return Ok(parts[0].to_string());
}
}
}
bail!("Failed to find remote name for {repo}");
}
fn extract_url_from_push(output: &str) -> String {
output
.lines()

View File

@ -1,6 +1,6 @@
use std::{collections::HashMap, io::Write, path::Path, process::Command};
use anyhow::{Result, ensure};
use anyhow::{Context, Result, bail, ensure};
use cargo_semver_checks::ReleaseType;
use clap::Args;
use esp_metadata::Chip;
@ -48,6 +48,25 @@ pub struct Plan {
pub packages: Vec<PackagePlan>,
}
impl Plan {
pub fn from_path(plan_path: &Path) -> Result<Self> {
let plan_source = std::fs::read_to_string(&plan_path)
.with_context(|| format!("Failed to read release plan from {}. Run `cargo xrelease plan` to generate a release plan.", plan_path.display()))?;
if plan_source.lines().any(|line| line.starts_with("//")) {
bail!(
"The release plan has not been finalized. Please open the plan and follow the instructions in it."
);
}
let plan = serde_json::from_str::<Plan>(&plan_source).with_context(|| {
format!("Failed to parse release plan from {}", plan_path.display())
})?;
Ok(plan)
}
}
pub fn plan(workspace: &Path, args: PlanArgs) -> Result<()> {
let current_branch = ensure_main_branch(args.allow_non_main)?;

View File

@ -0,0 +1,146 @@
use std::{path::Path, process::Command};
use anyhow::{Context, Result, bail, ensure};
use clap::Args;
use crate::{
cargo::{CargoArgsBuilder, CargoToml},
commands::Plan,
git::{current_branch, ensure_workspace_clean, get_remote_name_for},
};
#[derive(Debug, Args)]
pub struct PublishPlanArgs {
/// Do not pass the `--dry-run` argument, actually try to publish.
#[arg(long)]
no_dry_run: bool,
}
pub fn publish_plan(workspace: &Path, args: PublishPlanArgs) -> Result<()> {
ensure_workspace_clean(workspace)?;
let plan_path = workspace.join("release_plan.jsonc");
let plan_path = crate::windows_safe_path(&plan_path);
let plan = Plan::from_path(&plan_path)
.with_context(|| format!("Failed to read release plan from {}", plan_path.display()))?;
// Check that the publish command is being run on the right branch. This is
// meant to prevent publishing from the release branch before the changes
// are reviewed and merged.
ensure!(
current_branch()? == plan.base,
"The packages must be published from the same branch the plan was created on. \
Please switch to the {} branch and try again.",
plan.base
);
let tomls = plan
.packages
.iter()
.map(|step| {
CargoToml::new(workspace, step.package)
.with_context(|| format!("Failed to read Cargo.toml for {}", step.package))
})
.collect::<Result<Vec<_>>>()?;
// Check that all packages are updated and ready to go. This is meant to prevent
// publishing unupdated packages.
for (step, toml) in plan.packages.iter().zip(tomls.iter()) {
if toml.package_version() != step.new_version {
if toml.package_version() == step.current_version {
bail!(
"Package {} has not been updated yet. Run `cargo xrelease execute-plan` before publishing the packages.",
step.package
);
}
bail!(
"The version of package {} has changed in an unexpected way. Cannot continue.",
step.package
);
}
}
// Actually publish the packages.
for (step, toml) in plan.packages.iter().zip(tomls.iter()) {
let mut publish_args = if step.package.has_chip_features() {
vec!["--no-verify"]
} else {
vec![]
};
if !args.no_dry_run {
publish_args.push("--dry-run");
}
let builder = CargoArgsBuilder::default()
.subcommand("publish")
.args(&publish_args);
let args = builder.build();
log::debug!("{args:#?}");
// Execute `cargo publish` command from the package root:
crate::cargo::run(&args, &toml.package_path())?;
}
// Tag the releases
let mut new_tags = Vec::new();
for (step, toml) in plan.packages.iter().zip(tomls.iter()) {
let tag_name = toml.package.tag(&toml.package_version());
let tag_message = format!("{} {}", step.package, toml.version());
if args.no_dry_run {
let output = Command::new("git")
.arg("tag")
.arg("-m")
.arg(&tag_message)
.arg(&tag_name)
.current_dir(workspace)
.output()
.context("Failed to create git tag")?;
if !output.status.success() {
bail!(
"Failed to create git tag {}: {}",
tag_name,
String::from_utf8_lossy(&output.stderr)
);
}
println!("Tagged {} with message: {}", tag_name, tag_message);
} else {
println!(
"Dry run: would tag {} with message: {}",
tag_name, tag_message
);
}
new_tags.push(tag_name);
}
let upstream = get_remote_name_for("esp-rs/esp-hal")?;
if args.no_dry_run {
let output = Command::new("git")
.arg("push")
.arg(upstream)
.args(&new_tags)
.output()
.context("Failed to push git tags")?;
if !output.status.success() {
bail!(
"Failed to push git tags: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
println!(
"Dry run: would push the following tags to {upstream}: {}",
new_tags.join(", ")
);
}
println!("publish-plan completed successfully.");
Ok(())
}

View File

@ -12,3 +12,44 @@ pub fn current_branch() -> Result<String> {
Ok(String::from_utf8_lossy(&status.stdout).trim().to_string())
}
#[cfg(feature = "release")]
pub fn ensure_workspace_clean(workspace: &std::path::Path) -> Result<()> {
std::env::set_current_dir(workspace)
.with_context(|| format!("Failed to change directory to {}", workspace.display()))?;
let status = Command::new("git")
.arg("status")
.arg("--porcelain")
.output()
.context("Failed to check git status")?;
anyhow::ensure!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"The workspace is not clean. Please commit or stash your changes before running this command."
);
Ok(())
}
#[cfg(feature = "release")]
pub fn get_remote_name_for(repo: &str) -> Result<String> {
let remotes = Command::new("git")
.arg("remote")
.arg("-v")
.output()
.context("Failed to get remote URL")?;
let remotes = String::from_utf8_lossy(&remotes.stdout);
for line in remotes.lines() {
if line.contains(repo) {
let parts: Vec<_> = line.split_whitespace().collect();
if parts.len() >= 2 {
return Ok(parts[0].to_string());
}
}
}
anyhow::bail!("Failed to find remote name for {repo}");
}

View File

@ -131,6 +131,8 @@ fn main() -> Result<()> {
Release::Plan(args) => plan(&workspace, args),
#[cfg(feature = "release")]
Release::ExecutePlan(args) => execute_plan(&workspace, args),
#[cfg(feature = "release")]
Release::PublishPlan(args) => publish_plan(&workspace, args),
},
Cli::Ci(args) => run_ci_checks(&workspace, args),