From ad31ff8ab0713fa5fff844381f009cfc4796d92f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 31 May 2026 06:35:45 -0700 Subject: [PATCH 1/2] fix(ci-tooling): keep release auto-commit on release branch, not main The ship flow (git_ops::git_push) committed the 'chore: development vX.Y.Z' auto-commit on the current branch (main), then required 'git reset --hard origin/main' after the squash-merge because the squash rewrites the SHA. This left local main diverged from origin/main after every ship. Now the auto-commit lives on release/vX.Y.Z only: git_push switches onto the release branch (ensure_on_release_branch, replacing detect_current_branch), rebases onto origin/main, opens the PR against BASE_BRANCH (main), and returns the working tree to main (new return_to_base_branch helper). Local main never drifts, so the post-merge step becomes a plain 'git pull --ff-only origin main' instead of a destructive reset; the operator hint is updated to match. ensure_on_release_branch uses plain 'git switch' (not -C) so a resumed ship lands on the existing release branch holding the auto-commit. Closes the main-drift problem the ship flow had after squash-merges. --- CHANGELOG.md | 23 ++++ scripts/ci-pipeline/src/git_ops.rs | 204 +++++++++++++++++++++-------- 2 files changed, 176 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2135e859..3fba2e636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/ci-pipeline/src/git_ops.rs b/scripts/ci-pipeline/src/git_ops.rs index b2624727a..b8a5babd7 100644 --- a/scripts/ci-pipeline/src/git_ops.rs +++ b/scripts/ci-pipeline/src/git_ops.rs @@ -11,17 +11,23 @@ //! 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/`" 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; @@ -29,20 +35,70 @@ 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?; @@ -94,39 +150,48 @@ pub(crate) async fn count_unpushed_commits(remote_branch: &str) -> Result { // `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 ` (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 { - 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/` so the auto- -/// commit lands on top of any intervening mainline changes. +/// Rebase the currently-checked-out release branch onto +/// `origin/` 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 @@ -267,16 +332,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 /// @@ -289,33 +354,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, ¤t_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, ¤t_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(), @@ -327,11 +407,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(()) +} From 58472bdd375261df7fe3361a9583be1b6d7e046b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 31 May 2026 06:41:09 -0700 Subject: [PATCH 2/2] fix(ci-tooling): correct release PR-body text for release-branch flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open_release_pr body still described the retired step-09 behavior ('Binaries + GitHub Release already live') and told operators to recover with 'git reset --hard origin/main' — both contradict the new flow. Now the body explains binaries are built post-merge by auto-tag-release.yml -> release.yml, and that local main never drifts so syncing is a plain 'git pull --ff-only'. Matches the helper logic changed in this same branch. --- scripts/ci-pipeline/src/git_ops.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/ci-pipeline/src/git_ops.rs b/scripts/ci-pipeline/src/git_ops.rs index b8a5babd7..2e54631ea 100644 --- a/scripts/ci-pipeline/src/git_ops.rs +++ b/scripts/ci-pipeline/src/git_ops.rs @@ -261,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 \ @@ -273,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");