Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed — ship pipeline: release auto-commit no longer drifts local `main`

The `just ship` flow (`scripts/ci-pipeline` → `git_ops::git_push`) used to
create the `chore: development vX.Y.Z` auto-commit on the **current
branch (`main`)** and then instruct the operator to run `git reset --hard
origin/main` after the release PR squash-merged (because the squash
rewrites the commit SHA). That left local `main` ahead of / diverged
from `origin/main` after every ship and required a destructive reset to
recover.

- **Auto-commit now lives on the `release/vX.Y.Z` branch, never on
`main`.** `git_push` switches onto the release branch
(`ensure_on_release_branch`, replacing `detect_current_branch`),
rebases it onto `origin/main`, opens the PR against `BASE_BRANCH`
(`main`), and then returns the working tree to `main`
(new `return_to_base_branch` helper).
- **Local `main` no longer drifts**, so the post-merge step is a plain
`git pull --ff-only origin main` instead of `git reset --hard` — the
operator hint printed at the end of `git push` is updated to match.
- `ensure_on_release_branch` uses a plain `git switch` (not `-C`) so a
resumed ship lands on the existing release branch that already holds
the auto-commit rather than resetting it to a commit-less `HEAD`.

### Added — WinGet distribution: live install docs + auto-submission pipeline

`SkyLLC.UFFS` is published on the Windows Package Manager community
Expand Down
221 changes: 164 additions & 57 deletions scripts/ci-pipeline/src/git_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,94 @@
//! The entry points are [`git_commit`] (create the `chore: development
//! vX.Y.Z ...` signed commit) and [`git_push`] (push the release
//! branch + open the release PR with auto-merge queued). Each of the
//! push sub-steps (`detect_current_branch`, `rebase_onto_upstream`,
//! push sub-steps (`ensure_on_release_branch`, `rebase_onto_upstream`,
//! `push_release_branch`, `find_existing_release_pr`, `open_release_pr`,
//! `enable_auto_merge`) is its own small helper so a failure points
//! at a named function in the backtrace and refactors stay surgical.
//! `enable_auto_merge`, `return_to_base_branch`) is its own small helper
//! so a failure points at a named function in the backtrace and
//! refactors stay surgical.
//!
//! The auto-commit is created on the `release/vX.Y.Z` branch (see
//! [`git_commit`] / `switch_to_release_branch`), never on `main`, so a
//! ship leaves local `main` exactly at `origin/main` — no post-merge
//! `git reset --hard` is ever needed.
//!
//! [`count_unpushed_commits`] is the Phase 6 (resumable-push-fix)
//! helper: it lets the ship pipeline detect "HEAD is ahead of
//! `origin/release/<ver>`" and re-run the cached-completed push step
//! instead of silently skipping it.

use anyhow::{Context as _, Result, bail};
use anyhow::{Context as _, Result};
use colored::Colorize as _;
use tokio::process::Command;

use crate::context::PipelineContext;
use crate::exec::execute_command;
use crate::version::extract_version_from_cargo_toml;

/// Stage the release-branch working tree and create the auto-generated
/// `chore: development vX.Y.Z ... [auto-commit]` commit. Commit
/// message shape is stable so the release PR template can parse it.
/// Name of the long-lived base branch the release PR targets. The
/// ship pipeline never commits onto it locally (see [`git_commit`]); it
/// is only ever the PR base and the branch the developer is left on
/// after a ship.
pub(crate) const BASE_BRANCH: &str = "main";

/// Switch onto the `release/vX.Y.Z` branch (creating or resetting it to
/// the current `HEAD`) so the auto-commit lands there instead of on
/// `main`. Idempotent: safe to re-run on a resumed ship even if the
/// branch already exists.
///
/// Why this matters: committing on `main` left local `main` permanently
/// 1-ahead of `origin/main`, and after the PR squash-merged the local
/// commit's SHA diverged from GitHub's squashed commit, forcing a
/// `git reset --hard origin/main` after every ship. Committing on the
/// release branch keeps local `main` exactly at `origin/main`
/// throughout, so a plain `git pull --ff-only` syncs it post-merge.
///
/// `git switch -C` (capital C) creates-or-resets the branch to HEAD,
/// which is exactly the resumable semantics we want: first run creates
/// it at the pre-commit HEAD; a resumed run that is already on the
/// branch (with the commit) re-points it to the same HEAD as a no-op.
///
/// # Errors
///
/// Propagates any failure from the wrapped `git add`, the Cargo.toml
/// read, or the `git commit` subprocess.
/// Propagates any failure from the `git switch` subprocess.
async fn switch_to_release_branch(ctx: &PipelineContext, release_branch: &str) -> Result<()> {
println!(
"🌿 Switching to release branch {} (commit lands here, not on {})",
release_branch.cyan(),
BASE_BRANCH.cyan()
);
execute_command(
"Git switch (release branch)",
"git",
&["switch", "-C", release_branch],
ctx,
)
.await
}

/// Create the auto-generated `chore: development vX.Y.Z ... [auto-commit]`
/// commit **on the release branch** (never on `main`). Commit message
/// shape is stable so the release PR template can parse it.
///
/// Switches onto `release/vX.Y.Z` first (via [`switch_to_release_branch`])
/// so `main` is never mutated by the ship — see that helper's docs for
/// the local-`main`-divergence rationale.
///
/// # Errors
///
/// Propagates any failure from the branch switch, the wrapped `git add`,
/// the Cargo.toml read, or the `git commit` subprocess.
pub(crate) async fn git_commit(ctx: &PipelineContext) -> Result<()> {
println!("{}", "📝 Creating auto-generated commit...".blue());
execute_command("Git add", "git", &["add", "."], ctx).await?;

let cargo_toml = std::fs::read_to_string("Cargo.toml").context("Failed to read Cargo.toml")?;
let version = extract_version_from_cargo_toml(&cargo_toml)?;
let release_branch = format!("release/v{version}");

// Land the commit on the release branch, not on `main`.
switch_to_release_branch(ctx, &release_branch).await?;

execute_command("Git add", "git", &["add", "."], ctx).await?;
let commit_message =
format!("chore: development v{version} - comprehensive testing complete [auto-commit]");
execute_command("Git commit", "git", &["commit", "-m", &commit_message], ctx).await?;
Expand Down Expand Up @@ -94,39 +150,48 @@ pub(crate) async fn count_unpushed_commits(remote_branch: &str) -> Result<u64> {
// `git_push` sub-steps
// ─────────────────────────────────────────────────────────────────────────────

/// Read the current branch name via `git rev-parse --abbrev-ref HEAD`.
/// Ensure the working tree is on the existing `release_branch` before
/// pushing — without resetting it.
///
/// Uses plain `git switch <branch>` (NOT `-C`): on a resumed ship where
/// `git_commit` was cached-skipped, the release branch already exists
/// with the auto-commit and we must land on *that* commit, not reset the
/// branch to a commit-less `HEAD`. On a fresh run we are already on the
/// release branch (just created by `git_commit`), so the switch is a
/// no-op. `git switch` to the already-current branch succeeds silently.
///
/// # Errors
///
/// Returns an error if `git rev-parse` fails or the repo is in
/// detached-HEAD state (returns the literal `"HEAD"` in that case,
/// which is not a valid base branch for the release PR).
fn detect_current_branch() -> Result<String> {
let branch_output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.context("Failed to get current branch")?;
let current_branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_owned();
if current_branch.is_empty() || current_branch == "HEAD" {
bail!("Could not determine current branch (detached HEAD?)");
}
Ok(current_branch)
/// Propagates any failure from the `git switch` subprocess (e.g. the
/// branch does not exist — which would indicate `git_commit` never ran).
async fn ensure_on_release_branch(ctx: &PipelineContext, release_branch: &str) -> Result<()> {
execute_command(
"Git switch (ensure release branch)",
"git",
&["switch", release_branch],
ctx,
)
.await
}

/// Rebase `current_branch` onto `origin/<current_branch>` so the auto-
/// commit lands on top of any intervening mainline changes.
/// Rebase the currently-checked-out release branch onto
/// `origin/<BASE_BRANCH>` so the auto-commit lands on top of any
/// intervening mainline changes before the PR is opened.
///
/// (Previously this rebased the *current* branch onto its own upstream
/// — valid when the ship committed on `main`. Now that the commit
/// lives on `release/vX.Y.Z`, we rebase onto `origin/main` so the PR is
/// based on current mainline.)
///
/// # Errors
///
/// Propagates any failure from the wrapped `git pull --rebase`
/// subprocess (network, merge conflicts, ...).
async fn rebase_onto_upstream(ctx: &PipelineContext, current_branch: &str) -> Result<()> {
async fn rebase_onto_upstream(ctx: &PipelineContext) -> Result<()> {
execute_command(
"Git pull rebase",
"git",
&["pull", "origin", current_branch, "--rebase"],
&["pull", "origin", BASE_BRANCH, "--rebase"],
ctx,
)
.await
Expand Down Expand Up @@ -196,9 +261,13 @@ async fn open_release_pr(
let pr_title = format!("chore: release v{version} — ship pipeline auto-commit");
let pr_body = format!(
"## Summary\n\n\
`just ship` Phase 2 auto-commit for **v{version}**. Binaries + \
GitHub Release v{version} are already live (step 09). This PR \
routes the corresponding commit through branch-protection rules.\n\n\
`just ship` Phase 2 auto-commit for **v{version}** — the \
`[workspace.package].version` bump in `Cargo.toml`. This PR \
routes that commit through branch-protection rules. Once it \
merges to `{base_branch}`, `auto-tag-release.yml` tags the \
commit and invokes `release.yml`, which builds the \
cross-platform binaries and publishes GitHub Release \
v{version}.\n\n\
## Auto-merge\n\n\
`--auto --squash` is queued — GitHub will merge as soon as the \
required status checks pass. Squash is required because \
Expand All @@ -208,9 +277,10 @@ async fn open_release_pr(
satisfies `required_signatures: true`. The original author's \
signed commit remains verifiable in the PR branch history.\n\n\
## After merge\n\n\
Local `{base_branch}` had this commit with a different SHA \
before squash rewrote it onto main; recover with \
`git fetch origin && git reset --hard origin/{base_branch}`."
The auto-commit lived only on `{release_branch}`, so local \
`{base_branch}` never drifted — sync it with a plain \
`git pull --ff-only origin {base_branch}` (no `reset --hard` \
needed)."
);

println!("📬 Opening release PR");
Expand Down Expand Up @@ -267,16 +337,16 @@ async fn enable_auto_merge(ctx: &PipelineContext, release_branch: &str) -> Resul
.await
}

/// Push the release branch and open the release PR against the current
/// base branch. Branch-protection compatible: never pushes directly
/// to `main`.
/// Push the release branch and open the release PR against
/// [`BASE_BRANCH`]. Branch-protection compatible: never pushes
/// directly to `main`.
///
/// Thin orchestrator — each sub-step lives in its own helper
/// ([`detect_current_branch`], [`rebase_onto_upstream`],
/// ([`ensure_on_release_branch`], [`rebase_onto_upstream`],
/// [`push_release_branch`], [`find_existing_release_pr`],
/// [`open_release_pr`], [`enable_auto_merge`]) so the control flow
/// stays readable and individual failures map 1:1 to a named helper
/// in the backtrace.
/// [`open_release_pr`], [`enable_auto_merge`], [`return_to_base_branch`])
/// so the control flow stays readable and individual failures map 1:1
/// to a named helper in the backtrace.
///
/// # Errors
///
Expand All @@ -289,33 +359,48 @@ pub(crate) async fn git_push(ctx: &PipelineContext) -> Result<()> {
"🚀 Opening release PR (branch-protection-compatible)...".blue()
);

let current_branch = detect_current_branch()?;
println!("📌 Current branch: {}", current_branch.cyan());

// Stay current with upstream before opening the release PR.
rebase_onto_upstream(ctx, &current_branch).await?;

// Derive the release branch name from the workspace version that
// Phase 2 step 07 bumped (example: `release/v0.5.68`).
// The auto-commit was made on the release branch by `git_commit`, so
// that is the branch checked out here. Derive its name from the
// version Phase 2 step 07 bumped (example: `release/v0.5.68`).
let version = crate::version::get_current_version()?;
let release_branch = format!("release/v{version}");
println!("📌 Release branch: {}", release_branch.cyan());

// Resilience for resumed ships: if step 10 (git_commit) was
// cached-complete this run, we were NOT switched onto the release
// branch — and a prior git_push may have already switched us back to
// `main`. Re-checkout the existing release branch (plain `switch`,
// NOT `-C`, so we land on the branch that already holds the
// auto-commit rather than resetting it to a commit-less HEAD).
ensure_on_release_branch(ctx, &release_branch).await?;

// Rebase the release branch onto current mainline before opening the
// PR (keeps the PR based on the latest `origin/main`).
rebase_onto_upstream(ctx).await?;

push_release_branch(ctx, &release_branch).await?;

// Idempotent PR creation: reuse an existing open PR for the same
// release branch if the pipeline is resuming from a previously-
// failed step 11.
// failed step 11. Base is always `main` — the ship never targets
// any other branch.
match find_existing_release_pr(&release_branch)? {
Some(pr_number) => {
println!("ℹ️ Reusing existing release PR #{}", pr_number.cyan());
}
None => {
open_release_pr(ctx, &current_branch, &release_branch, &version).await?;
open_release_pr(ctx, BASE_BRANCH, &release_branch, &version).await?;
}
}

enable_auto_merge(ctx, &release_branch).await?;

// Leave the developer back on `main`, untouched and exactly at
// `origin/main`. Because the commit only ever lived on the release
// branch, local `main` never drifted — after the PR squash-merges,
// a plain `git pull --ff-only` syncs it (no `reset --hard` needed).
return_to_base_branch(ctx).await?;

println!(
"{} Release PR for v{} opened with auto-merge queued",
"✅".green(),
Expand All @@ -327,11 +412,33 @@ pub(crate) async fn git_push(ctx: &PipelineContext) -> Result<()> {
);
println!(
" 💡 After merge: {}",
format!(
"git fetch origin && git reset --hard origin/{current_branch} (squash rewrites commit SHA)"
)
.cyan()
format!("git pull --ff-only origin {BASE_BRANCH} (local {BASE_BRANCH} never drifted)")
.cyan()
);

Ok(())
}

/// Switch the working tree back to [`BASE_BRANCH`] after the release PR
/// has been opened, so the developer is left where they started.
///
/// Best-effort: a failure here does not undo the already-opened PR, so
/// it is logged but not fatal — the ship's real deliverable (the PR +
/// queued auto-merge) is already done by the time this runs.
///
/// # Errors
///
/// Never returns `Err` — a failed switch is downgraded to a warning so
/// it cannot fail an otherwise-successful ship.
async fn return_to_base_branch(ctx: &PipelineContext) -> Result<()> {
println!("🔙 Returning to {}", BASE_BRANCH.cyan());
if let Err(err) =
execute_command("Git switch (base)", "git", &["switch", BASE_BRANCH], ctx).await
{
println!(
"{} could not switch back to {BASE_BRANCH} ({err}); you are still on the release branch",
"⚠️".yellow()
);
}
Ok(())
}
Loading