mod support;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use loopflow::ops::{
bump_version, generate_release, preview_release_notes, release_bump, release_check,
release_notes, release_run, release_status, release_tag, NullProgress, ReleaseNotesDegradation,
ReleaseNotesStatus, ReleaseRunOutcome,
};
use loopflow_test_support::TestRepo;
use support::EnvGuard;
fn write_gh_script(pr_list: &str) -> String {
format!(
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh version 2.0.0'\n exit 0\nfi\ncase \"$1 $2\" in\n 'pr list')\n cat <<'JSON'\n{pr_list}\nJSON\n exit 0;;\nesac\necho \"unexpected gh invocation: $@\" >&2\nexit 1\n"
)
}
fn write_gh_status_script(run_list: &str, release_view: &str) -> String {
format!(
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh version 2.0.0'\n exit 0\nfi\ncase \"$1 $2\" in\n 'run list')\n case \"$*\" in *databaseId*) ;; *) echo 'databaseId was not requested' >&2; exit 1;; esac\n cat <<'JSON'\n{run_list}\nJSON\n exit 0;;\n 'release view')\n cat <<'JSON'\n{release_view}\nJSON\n exit 0;;\n 'pr list') echo '[]'; exit 0;;\nesac\necho \"unexpected gh invocation: $@\" >&2\nexit 1\n"
)
}
fn write_gh_incomplete_release_script() -> &'static str {
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh version 2.0.0'\n exit 0\nfi\ncase \"$1 $2\" in\n 'release view') exit 1;;\n 'run list') echo '[]'; exit 0;;\nesac\necho \"unexpected gh invocation: $@\" >&2\nexit 1\n"
}
fn write_gh_failed_release_script() -> &'static str {
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh version 2.0.0'\n exit 0\nfi\ncase \"$1 $2\" in\n 'release view') exit 1;;\n 'run list') cat <<'JSON'\n[{\"databaseId\":42,\"headBranch\":\"v0.9.1\",\"status\":\"completed\",\"conclusion\":\"failure\",\"url\":\"https://example.com/run/42\"}]\nJSON\n exit 0;;\n 'pr list') echo '[]'; exit 0;;\nesac\necho \"unexpected gh invocation: $@\" >&2\nexit 1\n"
}
fn write_gh_candidate_release_script(log_path: &str, conclusion: &str) -> String {
format!(
r#"#!/bin/sh
log="{log_path}"
dispatched="{log_path}.dispatched"
dispatch_count_file="{log_path}.dispatch-count"
branch_file="{log_path}.branch"
commit_file="{log_path}.commit"
configured_conclusion="{conclusion}"
if [ "$1" = "--version" ]; then
exit 0
fi
printf '%s\n' "$*" >> "$log"
case "$1 $2" in
'run list')
previous="$(git rev-list -n 1 v0.9.1)"
if [ -f "$dispatched" ]; then
branch="$(cat "$branch_file")"
commit="$(cat "$commit_file")"
run_conclusion="$configured_conclusion"
if [ "$run_conclusion" = failure-then-success ] && [ "$(cat "$dispatch_count_file")" -gt 1 ]; then
run_conclusion=success
elif [ "$run_conclusion" = failure-then-success ]; then
run_conclusion=failure
fi
printf '[{{"databaseId":84,"headBranch":"%s","headSha":"%s","displayTitle":"Release candidate v0.9.2","status":"completed","conclusion":"%s","url":"https://example.com/run/84"}},{{"databaseId":42,"headBranch":"v0.9.1","headSha":"%s","status":"completed","conclusion":"success","url":"https://example.com/run/42"}}]\n' "$branch" "$commit" "$run_conclusion" "$previous"
else
printf '[{{"databaseId":42,"headBranch":"v0.9.1","headSha":"%s","status":"completed","conclusion":"success","url":"https://example.com/run/42"}}]\n' "$previous"
fi
exit 0;;
'workflow run')
shift 2
branch=''
tag=''
while [ "$#" -gt 0 ]; do
case "$1" in
--ref) branch="$2"; shift 2 ;;
-f)
case "$2" in
tag=*) tag="${{2#tag=}}" ;;
esac
shift 2 ;;
*) shift ;;
esac
done
[ "$tag" = v0.9.2 ] || {{ echo "unexpected candidate tag: $tag" >&2; exit 51; }}
if git ls-remote --tags origin refs/tags/v0.9.2 | grep -q .; then
echo 'version tag existed before candidate proof' >&2
exit 52
fi
remote="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)"
[ -n "$remote" ] || {{ echo "candidate ref $branch is missing" >&2; exit 53; }}
case "$branch" in
release-candidate/default/v0-9-2/*) ;;
*) echo "unexpected candidate ref: $branch" >&2; exit 54 ;;
esac
commit="$remote"
printf '%s' "$branch" > "$branch_file"
printf '%s' "$commit" > "$commit_file"
count=0
[ ! -f "$dispatch_count_file" ] || count="$(cat "$dispatch_count_file")"
count=$((count + 1))
printf '%s' "$count" > "$dispatch_count_file"
: > "$dispatched"
exit 0;;
'pr list')
case " $* " in
*' --head '*)
head="$(git rev-parse HEAD)"
printf '[{{"number":1176,"state":"MERGED","mergeCommit":{{"oid":"%s"}},"url":"https://example.com/pr/1176","headRefOid":"%s"}}]\n' "$head" "$head"
;;
*) echo '[]' ;;
esac
exit 0;;
'run download') exit 0;;
'release view')
if [ "$3" = v0.9.1 ]; then
echo '{{"isDraft":false}}'
exit 0
fi
if [ -f "{log_path}.published" ]; then
echo '{{"isDraft":false}}'
exit 0
fi
exit 1;;
esac
echo "unexpected gh invocation: $*" >&2
exit 1
"#
)
}
fn write_gh_merge_wait_script(log_path: &str, queued: bool) -> String {
let queued = if queued { "1" } else { "0" };
let checks = support::github_checks_page("$head", &[]);
let unarmed = support::github_merge_response(1176, "$head", "OPEN", "CLEAN", None);
let pending =
support::github_merge_response(1176, "$head", "OPEN", "DIRTY", Some("queued:PR_release"));
let merged = support::github_merge_response(1176, "$head", "MERGED", "UNKNOWN", None);
format!(
r#"#!/bin/sh
log="{log_path}"
armed="{log_path}.armed"
queued="{queued}"
seen="{log_path}.seen"
if [ "$1" = "--version" ]; then
exit 0
fi
printf '%s\n' "$*" >> "$log"
if [ "$1 $2" = "api graphql" ]; then
case "$*" in
*LoopflowPrChecks*)
if [ "$queued" = 1 ]; then echo "original-head checks cannot settle queued work" >&2; exit 55; fi
head="$(git rev-parse HEAD)"
cat <<JSON
{checks}
JSON
exit 0 ;;
esac
fi
case "$1 $2" in
'pr list')
case " $* " in
*' --head '*)
head="$(git rev-parse HEAD)"
printf '[{{"number":1176,"state":"OPEN","mergeCommit":null,"url":"https://example.com/pr/1176","headRefOid":"%s"}}]\n' "$head"
;;
*) echo '[]' ;;
esac
exit 0;;
'api graphql')
head="$(git rev-parse HEAD)"
if [ "$queued" = 1 ] && [ ! -f "$seen" ]; then
touch "$seen"
cat <<JSON
{pending}
JSON
elif [ -f "$armed" ] || [ "$queued" = 1 ]; then
cat <<JSON
{merged}
JSON
else
cat <<JSON
{unarmed}
JSON
fi
exit 0;;
'pr merge')
if [ "$queued" = 1 ]; then echo 'queued work is already armed' >&2; exit 56; fi
touch "$armed"
exit 0;;
'release view') exit 1;;
esac
echo "unexpected gh invocation: $*" >&2
exit 1
"#
)
}
fn write_gh_dirty_release_script(
log_path: &str,
release_branch: &str,
main_branch: &str,
) -> String {
let checks = support::github_checks_page("$head", &[]);
let dirty = support::github_merge_response(1176, "$release_head", "OPEN", "DIRTY", None);
let open = support::github_merge_response(1176, "$release_head", "OPEN", "$merge_state", None);
let merged = support::github_merge_response(1176, "$release_head", "MERGED", "UNKNOWN", None);
format!(
r#"#!/bin/sh
log="{log_path}"
integrated="{log_path}.integrated"
seen="{log_path}.seen"
release_branch="{release_branch}"
main_branch="{main_branch}"
if [ "$1" = "--version" ]; then
exit 0
fi
printf '%s\n' "$*" >> "$log"
if [ "$1 $2" = "api graphql" ]; then
case "$*" in
*LoopflowPrChecks*)
head="$(git rev-parse HEAD)"
cat <<JSON
{checks}
JSON
exit 0 ;;
esac
fi
case "$1 $2" in
'pr list')
case " $* " in
*' --head '*)
head="$(git ls-remote origin "refs/heads/$release_branch" | cut -f1)"
printf '[{{"number":1176,"state":"OPEN","isDraft":false,"mergeCommit":null,"url":"https://example.com/pr/1176","headRefOid":"%s"}}]\n' "$head"
;;
*) echo '[]' ;;
esac
exit 0;;
'api graphql')
release_head="$(git ls-remote origin "refs/heads/$release_branch" | cut -f1)"
if [ -f "$integrated" ]; then
cat <<JSON
{merged}
JSON
elif [ ! -f "$seen" ]; then
touch "$seen"
cat <<JSON
{dirty}
JSON
else
main_head="$(git ls-remote origin "refs/heads/$main_branch" | cut -f1)"
if git merge-base --is-ancestor "$main_head" "$release_head"; then
merge_state=CLEAN
else
merge_state=BEHIND
fi
cat <<JSON
{open}
JSON
fi
exit 0;;
'pr merge')
release_head="$(git ls-remote origin "refs/heads/$release_branch" | cut -f1)"
main_head="$(git ls-remote origin "refs/heads/$main_branch" | cut -f1)"
if git merge-base --is-ancestor "$main_head" "$release_head"; then
touch "$integrated"
fi
exit 0;;
'pr ready'|'pr edit') exit 0;;
'release view') exit 1;;
esac
echo "unexpected gh invocation: $*" >&2
exit 1
"#
)
}
fn git(repo: &TestRepo, args: &[&str]) {
let _ = git_output(repo, args);
}
fn git_output(repo: &TestRepo, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo.path())
.output()
.expect("run git");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn git_output_bare(repo: &TestRepo, args: &[&str]) -> String {
let output = Command::new("git")
.arg("--git-dir")
.arg(repo.bare_path())
.args(args)
.output()
.expect("run git");
assert!(
output.status.success(),
"git --git-dir {:?} {:?} failed: {}",
repo.bare_path(),
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn wait_for_path(path: &std::path::Path) {
let deadline = Instant::now() + Duration::from_secs(20);
while !path.exists() {
assert!(
Instant::now() < deadline,
"timed out waiting for {}",
path.display()
);
thread::sleep(Duration::from_millis(20));
}
}
fn prepare_candidate_release_repo(repo: &TestRepo) -> String {
fs::create_dir_all(repo.path().join(".lf")).expect("create config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n workflow: release.yml\n",
)
.expect("write release config");
git(repo, &["add", ".lf/config.yaml"]);
git(repo, &["commit", "-m", "Configure release workflow"]);
git(repo, &["push", "origin", "HEAD"]);
git(repo, &["tag", "v0.9.1"]);
git(repo, &["push", "origin", "v0.9.1"]);
fs::write(repo.path().join("feature.txt"), "release me\n").expect("write feature");
git(repo, &["add", "feature.txt"]);
git(repo, &["commit", "-m", "Add release change"]);
git(repo, &["push", "origin", "HEAD"]);
git_output(repo, &["rev-parse", "HEAD"])
}
#[test]
fn release_generates_notes_and_writes_file() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.8.0"]);
fs::write(repo.path().join("feature.txt"), "released\n").unwrap();
git(&repo, &["add", "feature.txt"]);
git(&repo, &["commit", "-m", "Add release command"]);
let sha = git_output(&repo, &["rev-parse", "HEAD"]);
let pr_list = format!(
r#"[{{"number":101,"title":"Add release command","body":"Users can now run lf release.","mergeCommit":{{"oid":"{sha}"}}}}]"#
);
let gh_script = write_gh_script(&pr_list);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let version =
generate_release(repo.path(), "v0.9.1", &NullProgress).expect("release should succeed");
assert_eq!(version, "0.9.1");
let notes = fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).expect("read notes");
assert!(notes.starts_with("# v0.9.1\n\n"));
assert!(notes.contains("## Merged PRs"));
assert!(notes.contains("- #101 Add release command"));
}
#[test]
fn release_keeps_existing_header() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
generate_release(repo.path(), "0.9.2", &NullProgress).expect("release should succeed");
let notes = fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).expect("read notes");
assert_eq!(notes.lines().next().expect("first line"), "# v0.9.2");
assert_eq!(notes.matches("# v0.9.2").count(), 1);
}
#[test]
fn release_with_bump_keyword() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
let version =
generate_release(repo.path(), "patch", &NullProgress).expect("release should succeed");
assert_eq!(version, "0.9.2");
let notes = fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).expect("read notes");
assert!(notes.starts_with("# v0.9.2\n\n"));
}
#[test]
fn release_notes_falls_back_when_agent_cli_is_missing() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
fs::write(repo.path().join("weekly.txt"), "self-contained\n").unwrap();
git(&repo, &["add", "weekly.txt"]);
git(
&repo,
&["commit", "-m", "Make weekly release self-contained"],
);
let sha = git_output(&repo, &["rev-parse", "HEAD"]);
let pr_list = format!(
r#"[{{"number":101,"title":"Make weekly release self-contained","body":"Release automation no longer requires a workstation CLI.","additions":42,"deletions":7,"changedFiles":3,"mergeCommit":{{"oid":"{sha}"}}}}]"#
);
let gh_script = write_gh_script(&pr_list);
let lf_script =
"#!/bin/sh\necho \"'claude' CLI not found. Install it and rerun \\`lf init\\`.\" >&2\nexit 1\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
fs::create_dir_all(repo.path().join("release/unreleased")).unwrap();
fs::write(
repo.path().join("release/unreleased/CHANGES.md"),
"staged artifact\n",
)
.unwrap();
let notes = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect("release notes should fall back");
assert!(notes.starts_with("# v0.9.1\n\n"));
assert!(notes.contains("loopflow:release-notes=degraded;reason=missing-cli;gate=safe"));
assert!(notes.contains("_Generated mechanically for v0.9.1._"));
assert!(notes.contains("Make weekly release self-contained"));
assert!(!notes.contains("## Release decisions"));
assert_eq!(
fs::read_to_string(repo.path().join("release/v0.9.1/NOTES.md")).unwrap(),
notes,
);
assert!(!repo.path().join("release/unreleased").exists());
assert!(repo.path().join("release/v0.9.1/CHANGES.md").exists());
}
#[test]
fn release_notes_falls_back_for_every_provider_degradation_class() {
let gh_script = write_gh_script("[]");
let lf_script = "#!/bin/sh\ncat .provider-failure >&2\nexit 1\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let cases = [
(
"failed to select provider account: no eligible managed codex account: 'primary'",
ReleaseNotesDegradation::Cooldown,
),
(
"agent stopped after account subscription limit",
ReleaseNotesDegradation::Quota,
),
(
"agent stopped after account credential invalidated",
ReleaseNotesDegradation::Authentication,
),
(
"agent stopped after provider rate limit",
ReleaseNotesDegradation::RateLimit,
),
(
"agent stopped after provider unavailable",
ReleaseNotesDegradation::ProviderUnavailable,
),
(
"agent stopped after provider capacity",
ReleaseNotesDegradation::ProviderUnavailable,
),
(
"agent stopped after provider transport",
ReleaseNotesDegradation::ProviderUnavailable,
),
];
for (failure, degradation) in cases {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
fs::write(repo.path().join(".provider-failure"), failure).unwrap();
let notes = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect("provider degradation should select deterministic notes");
assert!(notes.contains(&format!(
"loopflow:release-notes=degraded;reason={degradation};gate=safe"
)));
assert!(notes.contains("_Generated mechanically for v0.9.1._"));
assert!(notes.len() < 60 * 1024);
}
}
#[test]
fn release_notes_bounds_oversized_pr_bodies_and_queue_metadata() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
fs::write(
repo.path().join("RELEASE_NOTES.md"),
"previous voice ".repeat(4_000),
)
.unwrap();
fs::create_dir_all(repo.path().join("release/unreleased")).unwrap();
fs::write(
repo.path().join("release/unreleased/DECISIONS.md"),
"bounded decision ".repeat(4_000),
)
.unwrap();
fs::write(repo.path().join("bounded.txt"), "bounded\n").unwrap();
git(&repo, &["add", "bounded.txt"]);
git(&repo, &["commit", "-m", "Bound release context"]);
let sha = git_output(&repo, &["rev-parse", "HEAD"]);
let pr_list = serde_json::to_string(&vec![serde_json::json!({
"number": 202,
"title": "Bound release context",
"body": "x".repeat(100_000),
"additions": 8,
"deletions": 2,
"changedFiles": 1,
"mergeCommit": {"oid": sha},
})])
.unwrap();
let gh_script = write_gh_script(&pr_list);
let lf_script = "#!/bin/sh\ncp \"$LF_RELEASE_NOTES_CONTEXT\" RELEASE_CONTEXT.json\nprintf '# v0.9.1\\n\\nBounded narrative notes.\\n' > RELEASE_NOTES.md\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let notes = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect("bounded narrative notes should succeed");
let context_path = repo.path().join("RELEASE_CONTEXT.json");
assert!(fs::metadata(&context_path).unwrap().len() <= 128 * 1024);
let context: serde_json::Value =
serde_json::from_str(&fs::read_to_string(context_path).unwrap()).unwrap();
assert!(context["merged_prs"][0]["body"].as_str().unwrap().len() <= 4 * 1024);
assert!(context["omissions"]["text_bytes"].as_u64().unwrap() >= 95_000);
assert!(context["decisions"].as_str().unwrap().len() <= 16 * 1024);
assert!(context["previous_release_notes"].as_str().unwrap().len() <= 16 * 1024);
assert!(context["omissions"]["decisions_bytes"].as_u64().unwrap() > 0);
assert!(
context["omissions"]["previous_release_notes_bytes"]
.as_u64()
.unwrap()
> 0
);
assert!(notes.len() < 60 * 1024);
assert!(!notes.contains(&"x".repeat(1_000)));
}
#[test]
fn interrupted_release_notes_retry_replaces_partial_state_once() {
let gh_script = write_gh_script("[]");
let lf_script = r#"#!/bin/sh
attempt=1
if [ -f .notes-attempt ]; then
attempt=2
fi
printf '%s' "$attempt" > .notes-attempt
cp "$LF_RELEASE_NOTES_CONTEXT" "RELEASE_CONTEXT.$attempt.json"
if [ "$attempt" = "1" ]; then
printf '# v0.9.1\n\nPartial notes that must not ship.\n' > RELEASE_NOTES.md
echo 'operator interrupted release-notes generation' >&2
exit 130
fi
printf '# v0.9.1\n\nConcise resumed release notes.\n' > RELEASE_NOTES.md
"#;
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
let previous_notes = "# v0.9.0\n\nPrevious safe notes.\n";
fs::write(repo.path().join("RELEASE_NOTES.md"), previous_notes).unwrap();
let error = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect_err("an unclassified interruption must keep the gate red");
assert!(error.to_string().contains("release gate blocked"));
assert_eq!(
fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).unwrap(),
previous_notes
);
let notes = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect("retry should replace partial state");
assert!(
fs::metadata(repo.path().join("RELEASE_CONTEXT.1.json"))
.unwrap()
.len()
<= 128 * 1024
);
assert!(
fs::metadata(repo.path().join("RELEASE_CONTEXT.2.json"))
.unwrap()
.len()
<= 128 * 1024
);
assert!(notes.contains("Concise resumed release notes."));
assert!(!notes.contains("Partial notes that must not ship."));
assert_eq!(notes.matches("loopflow:release-notes=").count(), 1);
let archived = fs::read_to_string(repo.path().join("release/v0.9.1/NOTES.md")).unwrap();
assert_eq!(archived, notes);
assert_eq!(
fs::read_dir(repo.path().join("release/v0.9.1"))
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name() == "NOTES.md")
.count(),
1
);
}
#[test]
fn successful_agent_without_fresh_notes_keeps_release_gate_unsafe() {
let gh_script = write_gh_script("[]");
let lf_script = "#!/bin/sh\nexit 0\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
let previous_notes = "# v0.9.0\n\nPrevious safe notes.\n";
fs::write(repo.path().join("RELEASE_NOTES.md"), previous_notes).unwrap();
let error = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect_err("missing fresh notes must stop the gate");
assert!(error.to_string().contains("release gate blocked"));
assert!(error.to_string().contains("fresh RELEASE_NOTES.md"));
assert_eq!(
fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).unwrap(),
previous_notes
);
}
#[test]
fn release_notes_rejects_a_successful_agent_with_stale_version() {
let gh_script = write_gh_script("[]");
let lf_script =
"#!/bin/sh\nprintf '# v0.9.0\\n\\nWrong generated release.\\n' > RELEASE_NOTES.md\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
let previous_notes = "# v0.9.0\n\nPrevious safe notes.\n";
fs::write(repo.path().join("RELEASE_NOTES.md"), previous_notes).unwrap();
let error = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect_err("stale-version notes must stop the gate");
assert!(error.to_string().contains("release gate blocked"));
assert!(error.to_string().contains("must start with '# v0.9.1'"));
assert_eq!(
fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).unwrap(),
previous_notes
);
}
#[test]
fn oversized_agent_notes_keep_queue_metadata_unsafe() {
let gh_script = write_gh_script("[]");
let lf_script = r#"#!/bin/sh
{
printf '# v0.9.1\n\n'
i=0
while [ "$i" -lt 7000 ]; do
printf '0123456789'
i=$((i + 1))
done
} > RELEASE_NOTES.md
"#;
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
let error = release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect_err("oversized queue metadata must stop the gate");
assert!(error.to_string().contains("release gate blocked"));
assert!(error.to_string().contains("maximum queue metadata"));
assert!(!repo.path().join("RELEASE_NOTES.md").exists());
}
#[test]
fn release_notes_context_fuses_decisions_with_exact_commits() {
let gh_script = write_gh_script("[]");
let lf_script = "#!/bin/sh\ncp \"$LF_RELEASE_NOTES_CONTEXT\" RELEASE_CONTEXT.json\nprintf '# v0.9.1\\n\\nPrepared from context.\\n' > RELEASE_NOTES.md\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
fs::write(repo.path().join("feature.txt"), "release behavior\n").unwrap();
git(&repo, &["add", "feature.txt"]);
git(&repo, &["commit", "-m", "Ship release behavior"]);
fs::create_dir_all(repo.path().join("release/unreleased")).unwrap();
fs::write(
repo.path().join("release/unreleased/DECISIONS.md"),
"Prefer explicit completion evidence.\n",
)
.unwrap();
release_notes(repo.path(), "0.9.1", Some("v0.9.0"), None, &NullProgress)
.expect("release notes should succeed");
let context: serde_json::Value = serde_json::from_str(
&fs::read_to_string(repo.path().join("RELEASE_CONTEXT.json")).unwrap(),
)
.unwrap();
assert_eq!(
context["decisions"],
"Prefer explicit completion evidence.\n"
);
assert_eq!(context["commits"][0]["title"], "Ship release behavior");
assert!(context["merged_prs"].as_array().unwrap().is_empty());
}
#[test]
fn bump_version_patch() {
assert_eq!(bump_version("v1.2.3", "patch").unwrap(), "1.2.4");
}
#[test]
fn bump_version_minor() {
assert_eq!(bump_version("1.2.3", "minor").unwrap(), "1.3.0");
}
#[test]
fn bump_version_major() {
assert_eq!(bump_version("v0.9.1", "major").unwrap(), "1.0.0");
}
#[test]
fn bump_version_invalid_format() {
assert!(bump_version("1.2", "patch").is_err());
}
#[test]
fn release_check_returns_merged_prs() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
fs::write(repo.path().join("feature.txt"), "new stuff\n").unwrap();
git(&repo, &["add", "feature.txt"]);
git(&repo, &["commit", "-m", "Add feature"]);
let sha = git_output(&repo, &["rev-parse", "HEAD"]);
let old_sha = git_output(&repo, &["rev-parse", "v0.9.0"]);
let pr_list = format!(
r#"[{{"number":41,"title":"Old same-day change","body":"Already tagged","mergeCommit":{{"oid":"{old_sha}"}}}},{{"number":42,"title":"Add feature","body":"New stuff","additions":100,"deletions":10,"changedFiles":3,"mergeCommit":{{"oid":"{sha}"}}}}]"#
);
let gh_script = write_gh_script(&pr_list);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let changes = release_check(repo.path(), None).expect("check should succeed");
assert_eq!(changes.commits.len(), 1);
assert_eq!(changes.commits[0].title, "Add feature");
assert_eq!(changes.merged_prs.len(), 1);
assert_eq!(changes.merged_prs[0].number, 42);
assert_eq!(changes.merged_prs[0].title, "Add feature");
}
#[test]
fn release_check_uses_direct_commits_as_release_truth() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
fs::write(repo.path().join("direct.txt"), "shipped without a PR\n").unwrap();
git(&repo, &["add", "direct.txt"]);
git(&repo, &["commit", "-m", "Ship a direct commit"]);
let changes = release_check(repo.path(), None).expect("check should succeed");
assert_eq!(changes.commits.len(), 1);
assert_eq!(changes.commits[0].title, "Ship a direct commit");
assert!(changes.merged_prs.is_empty());
}
#[test]
fn release_check_reconciles_version_tags_to_origin() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
git(&repo, &["push", "origin", "v0.9.0"]);
let stale_sha = git_output(&repo, &["rev-parse", "v0.9.0"]);
fs::write(repo.path().join("repair.txt"), "remote release truth\n").unwrap();
git(&repo, &["add", "repair.txt"]);
git(&repo, &["commit", "-m", "Repair release tag"]);
let remote_sha = git_output(&repo, &["rev-parse", "HEAD"]);
git(&repo, &["push", "origin", "HEAD:refs/heads/tag-repair"]);
git_output_bare(
&repo,
&["update-ref", "refs/tags/v0.9.0", &remote_sha, &stale_sha],
);
let changes = release_check(repo.path(), None).expect("check should reconcile tags");
assert!(changes.commits.is_empty());
assert_eq!(git_output(&repo, &["rev-parse", "v0.9.0"]), remote_sha);
}
#[test]
fn release_check_reads_evidence_without_running_repository_hooks() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).unwrap();
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n app:\n verify:\n - exit 19\n",
)
.unwrap();
let changes = release_check(repo.path(), Some("app")).expect("check should stay read-only");
assert!(!changes.commits.is_empty());
}
#[test]
fn release_check_returns_empty_without_tag() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
let changes = release_check(repo.path(), None).expect("check should succeed");
assert!(!changes.commits.is_empty());
assert!(changes.merged_prs.is_empty());
}
#[test]
fn minor_notes_cover_the_cycle_and_preview_preserves_release_files() {
let gh_script = write_gh_script("[]");
let lf_script = r#"#!/bin/sh
cp "$LF_RELEASE_NOTES_CONTEXT" observed-context.json
printf '# v0.10.0\n\nCycle highlights.\n' > "$LF_RELEASE_NOTES_OUTPUT"
"#;
let _env = EnvGuard::new(&[("gh", &gh_script), ("lf", lf_script)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.0"]);
repo.create_file("early.txt", "already shipped in a patch");
repo.stage_all();
repo.commit("Early improvement");
git(&repo, &["tag", "v0.9.1"]);
repo.create_file("late.txt", "closing patch improvement");
repo.stage_all();
repo.commit("Late improvement");
let original = "# v0.9.1\n\nExisting release notes.\n";
repo.create_file("RELEASE_NOTES.md", original);
repo.create_file(
"release/unreleased/DECISIONS.md",
"Preserve cycle intent.\n",
);
let notes = preview_release_notes(repo.path(), "0.10.0", None, None, &NullProgress).unwrap();
let context: serde_json::Value =
serde_json::from_slice(&fs::read(repo.path().join("observed-context.json")).unwrap())
.unwrap();
assert_eq!(context["prev_tag"], "v0.9.0");
let titles: Vec<_> = context["commits"]
.as_array()
.unwrap()
.iter()
.map(|commit| commit["title"].as_str().unwrap())
.collect();
assert_eq!(titles, ["Early improvement", "Late improvement"]);
assert!(notes.contains("Cycle highlights."));
assert_eq!(
fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).unwrap(),
original
);
assert!(repo.path().join("release/unreleased/DECISIONS.md").exists());
assert!(!repo.path().join("release/v0.10.0").exists());
assert_eq!(git_output(&repo, &["tag", "--list"]), "v0.9.0\nv0.9.1");
}
#[test]
fn historical_minor_preview_excludes_later_changes_and_context() {
let gh_script = write_gh_script("[]");
let lf_script = r#"#!/bin/sh
cp "$LF_RELEASE_NOTES_CONTEXT" observed-context.json
printf '# v0.10.0\n\nHistorical cycle.\n' > "$LF_RELEASE_NOTES_OUTPUT"
"#;
let _env = EnvGuard::new(&[("gh", &gh_script), ("lf", lf_script)]);
let repo = TestRepo::new();
repo.create_file("RELEASE_NOTES.md", "Previous notes");
repo.stage_all();
repo.commit("Previous release");
git(&repo, &["tag", "v0.9.0"]);
repo.create_file("release/v0.10.0/DECISIONS.md", "Historical intent");
repo.create_file("RELEASE_NOTES.md", "Archived notes");
repo.stage_all();
repo.commit("Historical improvement");
git(&repo, &["tag", "v0.10.0"]);
repo.create_file("later.txt", "Must not appear");
repo.create_file("RELEASE_NOTES.md", "Future notes");
repo.create_file("release/unreleased/DECISIONS.md", "Future intent");
repo.stage_all();
repo.commit("Future improvement");
preview_release_notes(repo.path(), "0.10.0", None, None, &NullProgress).unwrap();
let context: serde_json::Value =
serde_json::from_slice(&fs::read(repo.path().join("observed-context.json")).unwrap())
.unwrap();
assert_eq!(context["commits"].as_array().unwrap().len(), 1);
assert_eq!(context["commits"][0]["title"], "Historical improvement");
assert_eq!(context["decisions"], "Historical intent");
assert_eq!(context["previous_release_notes"], "Previous notes");
assert_eq!(
fs::read_to_string(repo.path().join("RELEASE_NOTES.md")).unwrap(),
"Future notes"
);
}
#[test]
fn minor_notes_require_the_cycle_baseline_and_support_named_targets() {
let gh_script = write_gh_script("[]");
let lf_script = r#"#!/bin/sh
cp "$LF_RELEASE_NOTES_CONTEXT" observed-context.json
printf '# v1.3.0\n\nCLI cycle highlights.\n' > "$LF_RELEASE_NOTES_OUTPUT"
"#;
let _env = EnvGuard::new(&[("gh", &gh_script), ("lf", lf_script)]);
let repo = TestRepo::new();
repo.create_file(
".lf/config.yaml",
"release:\n targets:\n cli:\n tag_prefix: cli/\n",
);
git(&repo, &["tag", "cli/v1.2.7"]);
let error =
preview_release_notes(repo.path(), "1.3.0", None, Some("cli"), &NullProgress).unwrap_err();
assert!(error.to_string().contains("cli/v1.2.0"));
git(&repo, &["tag", "cli/v1.2.0"]);
preview_release_notes(repo.path(), "1.3.0", None, Some("cli"), &NullProgress).unwrap();
let context: serde_json::Value =
serde_json::from_slice(&fs::read(repo.path().join("observed-context.json")).unwrap())
.unwrap();
assert_eq!(context["prev_tag"], "cli/v1.2.0");
}
#[test]
fn release_run_is_a_green_noop_without_merged_changes() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("empty release should succeed");
assert_eq!(
outcome,
ReleaseRunOutcome::NoChanges {
target: "default".to_string(),
latest_tag: Some("v0.9.1".to_string()),
}
);
}
fn minor_release_scripts(state: &std::path::Path) -> (String, String) {
let checks = support::github_checks_page("$head", &[]);
let gh = format!(
r#"#!/bin/sh
set -eu
state='{state}'
[ "$1" != --version ] || exit 0
branch=''
if [ "$1 $2" = "api graphql" ]; then
case "$*" in
*LoopflowPrChecks*)
head="$(git rev-parse HEAD)"
cat <<JSON
{checks}
JSON
exit 0 ;;
esac
fi
case "$1 $2" in
'pr list')
previous=''
for arg in "$@"; do
[ "$previous" != --head ] || branch="$arg"
previous="$arg"
done
[ -n "$branch" ] || {{ echo '[]'; exit 0; }} ;;
'pr view'|'pr merge')
case "${{3:-}}" in
92) branch=jack/release-default-v0-9-2 ;;
100) branch=jack/release-default-v0-10-0 ;;
*) branch=$(git branch --show-current) ;;
esac ;;
'pr create'|'pr edit'|'pr ready') exit 0 ;;
'api graphql')
case " $* " in
*' number=92 '*) branch=jack/release-default-v0-9-2 ;;
*' number=100 '*) branch=jack/release-default-v0-10-0 ;;
*) exit 1 ;;
esac ;;
'release view') echo '{{"isDraft":false}}'; exit 0 ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
case "$branch" in
jack/release-default-v0-9-2) number=92; version=0.9.2 ;;
jack/release-default-v0-10-0) number=100; version=0.10.0 ;;
*) exit 1 ;;
esac
head=$(git ls-remote origin "refs/heads/$branch" | cut -f1)
if [ -z "$head" ] || ! git cat-file -e "$head:release/v$version/NOTES.md" 2>/dev/null; then
[ "$2" != list ] || {{ echo '[]'; exit 0; }}
exit 1
fi
if [ "$2" = merge ]; then
if [ "$number" = 100 ] && [ -f "$state/advance-main" ]; then
GIT_INDEX_FILE="$state/index" git read-tree "$head"
blob=$(printf 'late feature\n' | git hash-object -w --stdin)
GIT_INDEX_FILE="$state/index" git update-index --add --cacheinfo "100644,$blob,late.txt"
tree=$(GIT_INDEX_FILE="$state/index" git write-tree)
head=$(printf 'Merge with concurrent feature\n' | git commit-tree "$tree" -p "$head")
fi
git push origin "$head:refs/heads/main" >&2
printf '%s' "$head" > "$state/merged-$number"
exit 0
fi
status=OPEN
merge=null
if [ -f "$state/merged-$number" ]; then
status=MERGED
merge="{{\"oid\":\"$(cat "$state/merged-$number")\"}}"
fi
result=$(printf '{{"id":"PR_release","isMergeQueueEnabled":false,"autoMergeRequest":null,"mergeQueueEntry":null,"mergedAt":null,"number":%s,"state":"%s","isDraft":false,"mergeStateStatus":"CLEAN","mergeCommit":%s,"headRefOid":"%s","headRefName":"%s","baseRefName":"main","url":"https://example.com/pr/%s","title":"Release","body":"Release notes","statusCheckRollup":[]}}' "$number" "$status" "$merge" "$head" "$branch" "$number")
if [ "$2" = list ]; then printf '[%s]\n' "$result";
elif [ "$1 $2" = "api graphql" ]; then printf '{{"data":{{"repository":{{"pullRequest":%s}}}}}}\n' "$result";
else printf '%s\n' "$result"; fi
"#,
state = state.display()
);
let lf = format!(
r#"#!/bin/sh
set -eu
version=$(sed 's/^{{"version":"\([^"]*\)".*/\1/' "$LF_RELEASE_NOTES_CONTEXT")
cp "$LF_RELEASE_NOTES_CONTEXT" '{state}'/context-$version.json
if [ "$version" = 0.10.0 ] && [ -f '{state}/interrupt' ]; then
echo 'operator interrupted minor notes' >&2
exit 1
fi
printf '# v%s\n\nRelease highlights.\n' "$version" > "$LF_RELEASE_NOTES_OUTPUT"
"#,
state = state.display()
);
(gh, lf)
}
fn prepare_minor_cycle(repo: &TestRepo, outstanding: bool) {
repo.create_file(".gitignore", ".lf/releases/\n");
repo.create_file(
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.9.0\"\n",
);
repo.stage_all();
repo.commit("Start cycle");
git(repo, &["tag", "v0.9.0"]);
repo.create_file("feature.txt", "same product in patch and minor\n");
repo.create_file(
"Cargo.toml",
"[package]\nname = \"demo\"\nversion = \"0.9.1\"\n",
);
repo.stage_all();
repo.commit("Ship cycle improvement");
git(repo, &["tag", "v0.9.1"]);
if outstanding {
repo.create_file("fix.txt", "closing patch fix\n");
repo.stage_all();
repo.commit("Closing fix");
}
git(repo, &["push", "origin", "main", "--tags"]);
}
#[test]
fn minor_release_closes_outstanding_changes_with_a_patch() {
let repo = TestRepo::new();
prepare_minor_cycle(&repo, true);
let state = tempfile::tempdir().unwrap();
let (gh, lf) = minor_release_scripts(state.path());
let _env = EnvGuard::new(&[("gh", &gh), ("lf", &lf)]);
let result = release_run(repo.path(), "0.10.0", None, &NullProgress).unwrap();
let ReleaseRunOutcome::Released(receipt) = result else {
panic!("expected minor release")
};
assert_eq!(receipt.tag, "v0.10.0");
assert_eq!(
git_output(&repo, &["tag", "--list"]),
"v0.10.0\nv0.9.0\nv0.9.1\nv0.9.2"
);
assert_eq!(
git_output(
&repo,
&["diff", "v0.9.2", "v0.10.0", "--", "feature.txt", "fix.txt"]
),
""
);
assert!(git_output(&repo, &["show", "v0.10.0:Cargo.toml"]).contains("0.10.0"));
let patch: serde_json::Value =
serde_json::from_slice(&fs::read(state.path().join("context-0.9.2.json")).unwrap())
.unwrap();
let minor: serde_json::Value =
serde_json::from_slice(&fs::read(state.path().join("context-0.10.0.json")).unwrap())
.unwrap();
assert_eq!(patch["prev_tag"], "v0.9.1");
assert_eq!(minor["prev_tag"], "v0.9.0");
assert!(minor["commits"]
.as_array()
.unwrap()
.iter()
.any(|commit| commit["title"] == "Ship cycle improvement"));
}
#[test]
fn minor_release_reuses_the_latest_patch_without_an_empty_patch() {
let repo = TestRepo::new();
prepare_minor_cycle(&repo, false);
let state = tempfile::tempdir().unwrap();
let (gh, lf) = minor_release_scripts(state.path());
let _env = EnvGuard::new(&[("gh", &gh), ("lf", &lf)]);
let result = release_run(repo.path(), "minor", None, &NullProgress).unwrap();
let ReleaseRunOutcome::Released(receipt) = result else {
panic!("expected minor release")
};
assert_eq!(receipt.tag, "v0.10.0");
assert_eq!(
git_output(&repo, &["tag", "--list"]),
"v0.10.0\nv0.9.0\nv0.9.1"
);
assert_eq!(
git_output(&repo, &["diff", "v0.9.1", "v0.10.0", "--", "feature.txt"]),
""
);
assert!(matches!(
release_run(repo.path(), "minor", None, &NullProgress).unwrap(),
ReleaseRunOutcome::NoChanges { .. }
));
}
#[test]
fn interrupted_minor_reuses_its_published_patch() {
let repo = TestRepo::new();
prepare_minor_cycle(&repo, true);
let state = tempfile::tempdir().unwrap();
fs::write(state.path().join("interrupt"), "").unwrap();
let (gh, lf) = minor_release_scripts(state.path());
let _env = EnvGuard::new(&[("gh", &gh), ("lf", &lf)]);
let error = release_run(repo.path(), "minor", None, &NullProgress).unwrap_err();
assert!(
error.to_string().contains("interrupted minor notes"),
"{error}"
);
let patch = git_output(&repo, &["rev-parse", "v0.9.2"]);
assert_eq!(git_output(&repo, &["tag", "--list", "v0.10.0"]), "");
fs::remove_file(state.path().join("interrupt")).unwrap();
let result = release_run(repo.path(), "minor", None, &NullProgress).unwrap();
let ReleaseRunOutcome::Released(receipt) = result else {
panic!("expected resumed pair to finish")
};
assert_eq!(receipt.tag, "v0.10.0");
assert_eq!(git_output(&repo, &["rev-parse", "v0.9.2"]), patch);
assert_eq!(git_output(&repo, &["tag", "--list", "v0.9.3"]), "");
let receipt_path = repo.path().join(".lf/releases/minor-default.json");
let mut receipt: serde_json::Value =
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
receipt["completed"] = false.into();
fs::write(receipt_path, serde_json::to_vec(&receipt).unwrap()).unwrap();
let result = release_run(repo.path(), "minor", None, &NullProgress).unwrap();
let ReleaseRunOutcome::Resumed(receipt) = result else {
panic!("expected the already-published minor to resume")
};
assert_eq!(receipt.tag, "v0.10.0");
assert_eq!(git_output(&repo, &["tag", "--list", "v0.11.0"]), "");
}
#[test]
fn minor_release_does_not_tag_a_merge_that_changed_its_patch_snapshot() {
let repo = TestRepo::new();
prepare_minor_cycle(&repo, false);
let state = tempfile::tempdir().unwrap();
fs::write(state.path().join("advance-main"), "").unwrap();
let (gh, lf) = minor_release_scripts(state.path());
let _env = EnvGuard::new(&[("gh", &gh), ("lf", &lf)]);
let error = release_run(repo.path(), "minor", None, &NullProgress).unwrap_err();
assert!(error.to_string().contains("late.txt"), "{error}");
assert_eq!(git_output(&repo, &["tag", "--list", "v0.10.0"]), "");
assert_eq!(git_output_bare(&repo, &["tag", "--list", "v0.10.0"]), "");
}
#[test]
fn release_run_checks_the_host_local_publisher_role() {
let gh_script = write_gh_status_script("[]", r#"{"isDraft":false}"#);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
let checked = repo.path().join("publisher-checked");
let publisher = repo.path().join("publisher.sh");
fs::write(
&publisher,
format!(
"#!/bin/sh\n[ \"$1\" = check ] || exit 19\n: > '{}'\n",
checked.display()
),
)
.expect("write publisher");
fs::create_dir_all(repo.path().join(".lf")).expect("create config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n publisher: [\"sh\", \"{repo}/publisher.sh\"]\n",
)
.expect("write config");
git(&repo, &["add", ".lf/config.yaml", "publisher.sh"]);
git(&repo, &["commit", "-m", "Configure publisher role"]);
git(&repo, &["push", "origin", "HEAD"]);
git(&repo, &["tag", "v0.9.1"]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("configured publisher should pass preflight");
assert!(checked.exists());
assert_eq!(
outcome,
ReleaseRunOutcome::NoChanges {
target: "default".to_string(),
latest_tag: Some("v0.9.1".to_string()),
}
);
}
#[test]
fn release_run_proves_the_candidate_before_pushing_the_version_tag() {
let repo = TestRepo::new();
let head = prepare_candidate_release_repo(&repo);
let state = tempfile::tempdir().expect("release state");
let log_path = state.path().join("gh.log");
let gh_script = write_gh_candidate_release_script(&log_path.to_string_lossy(), "success");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("proven candidate should be tagged");
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("expected a released receipt");
};
assert_eq!(receipt.tag, "v0.9.2");
assert_eq!(receipt.commit, head);
assert_eq!(receipt.workflow_run_id, 84);
assert_eq!(
git_output_bare(&repo, &["rev-parse", "refs/tags/v0.9.2"]),
head
);
assert!(
git_output_bare(
&repo,
&[
"for-each-ref",
"--format=%(refname)",
"refs/heads/release-candidate/",
],
)
.is_empty(),
"successful release should remove its provisional ref"
);
}
#[test]
fn release_run_prepares_signed_artifacts_before_pushing_the_version_tag() {
let repo = TestRepo::new();
prepare_candidate_release_repo(&repo);
let state = tempfile::tempdir().expect("release state");
let log_path = state.path().join("gh.log");
let allow_preparation = state.path().join("allow-preparation");
let publisher = repo.path().join("publisher.sh");
fs::write(
&publisher,
format!(
r#"#!/bin/sh
case "$1" in
check) exit 0 ;;
prepare)
if git ls-remote --tags origin refs/tags/v0.9.2 | grep -q .; then
echo 'version tag existed before publisher preparation' >&2
exit 61
fi
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--output) output="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -f '{}' ] || {{ echo 'candidate signing failed' >&2; exit 63; }}
mkdir -p "$output"
printf '{{}}\n' > "$output/candidate.json"
exit 0 ;;
publish)
git ls-remote --tags origin refs/tags/v0.9.2 | grep -q . || {{
echo 'version tag missing during publication' >&2
exit 62
}}
: > '{}.published'
exit 0 ;;
esac
exit 2
"#,
allow_preparation.display(),
log_path.display(),
),
)
.expect("write publisher");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n workflow: release.yml\n publisher: [\"sh\", \"{repo}/publisher.sh\"]\n",
)
.expect("configure publisher");
git(&repo, &["add", ".lf/config.yaml", "publisher.sh"]);
git(&repo, &["commit", "-m", "Configure candidate publisher"]);
git(&repo, &["push", "origin", "HEAD"]);
let head = git_output(&repo, &["rev-parse", "HEAD"]);
let gh_script = write_gh_candidate_release_script(&log_path.to_string_lossy(), "success");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("failed candidate signing must stop before tagging");
assert!(error.to_string().contains("candidate signing failed"));
assert!(git_output_bare(
&repo,
&["for-each-ref", "--format=%(refname)", "refs/tags/v0.9.2"],
)
.is_empty());
fs::write(allow_preparation, "").expect("allow candidate preparation");
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("prepared candidate should publish");
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("expected a released receipt");
};
assert_eq!(receipt.commit, head);
assert!(receipt.release_exists);
}
#[test]
fn release_run_owns_each_interrupted_candidate_preparation_materialization() {
for interrupted in [
InterruptedWorktree::EmptyPath,
InterruptedWorktree::BranchOnly,
InterruptedWorktree::MissingPath,
InterruptedWorktree::Complete,
] {
let repo = TestRepo::new();
let state = tempfile::tempdir().expect("release state");
let fixture = configure_candidate_publisher(&repo, state.path());
let worktree = leave_interrupted_worktree(
&repo,
interrupted,
&fixture.worktree_name,
&fixture.branch,
&fixture.head,
);
let _env = EnvGuard::new(&[("gh", fixture.gh_script.as_str())]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.unwrap_or_else(|error| panic!("{interrupted:?} retry failed: {error}"));
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("{interrupted:?} should release the prepared candidate");
};
assert_eq!(receipt.commit, fixture.head);
assert_eq!(
fs::read_to_string(&fixture.attempts).expect("candidate preparation attempt"),
"prepare\n"
);
assert!(receipt.release_exists);
assert!(!worktree.exists());
}
}
#[test]
fn release_run_preserves_dirty_candidate_preparation_worktree() {
let repo = TestRepo::new();
let state = tempfile::tempdir().expect("release state");
let fixture = configure_candidate_publisher(&repo, state.path());
let worktree = leave_interrupted_worktree(
&repo,
InterruptedWorktree::Complete,
&fixture.worktree_name,
&fixture.branch,
&fixture.head,
);
fs::write(worktree.join("operator.txt"), "preserve me\n").expect("dirty candidate tree");
let branch_head = git_output(&repo, &["rev-parse", &fixture.branch]);
let _env = EnvGuard::new(&[("gh", fixture.gh_script.as_str())]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("dirty candidate state must fail closed");
let message = error.to_string();
assert!(message.contains(&worktree.display().to_string()));
assert!(message.contains(&fixture.branch));
assert!(message.contains(&fixture.head));
assert!(message.contains("operator.txt"));
assert!(message.contains("no state was changed"));
assert_eq!(
fs::read_to_string(worktree.join("operator.txt")).expect("preserved file"),
"preserve me\n"
);
assert_eq!(
git_output(&repo, &["rev-parse", &fixture.branch]),
branch_head
);
assert!(!fixture.attempts.exists());
}
#[test]
fn release_run_preserves_divergent_candidate_preparation_worktree() {
let repo = TestRepo::new();
let state = tempfile::tempdir().expect("release state");
let fixture = configure_candidate_publisher(&repo, state.path());
let worktree = leave_interrupted_worktree(
&repo,
InterruptedWorktree::Complete,
&fixture.worktree_name,
&fixture.branch,
"v0.9.1",
);
let branch_head = git_output(&repo, &["rev-parse", &fixture.branch]);
let _env = EnvGuard::new(&[("gh", fixture.gh_script.as_str())]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("divergent candidate state must fail closed");
let message = error.to_string();
assert!(message.contains(&worktree.display().to_string()));
assert!(message.contains(&fixture.branch));
assert!(message.contains(&fixture.head));
assert!(message.contains(&branch_head));
assert!(message.contains("no state was changed"));
assert!(worktree.exists());
assert_eq!(
git_output(&repo, &["rev-parse", &fixture.branch]),
branch_head
);
assert!(!fixture.attempts.exists());
}
#[test]
fn active_candidate_preparation_blocks_concurrent_cleanup_until_exit() {
let repo = TestRepo::new();
let state = tempfile::tempdir().expect("release state");
let fixture = configure_candidate_publisher(&repo, state.path());
fs::write(&fixture.block, "").expect("block candidate preparation");
let _env = EnvGuard::new(&[("gh", fixture.gh_script.as_str())]);
let release = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["release", "run", "patch"])
.current_dir(repo.path())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("start candidate preparation");
wait_for_path(&fixture.ready);
assert!(fixture.worktree.exists());
let branch_head = git_output(&repo, &["rev-parse", &fixture.branch]);
let removal = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["wt", "delete", fixture.worktree_name.as_str()])
.current_dir(repo.path())
.output()
.expect("attempt concurrent candidate cleanup");
assert!(!removal.status.success());
assert!(
String::from_utf8_lossy(&removal.stderr)
.contains("release candidate preparation for v0.9.2"),
"unexpected cleanup error: {}",
String::from_utf8_lossy(&removal.stderr)
);
assert!(fixture.worktree.exists());
assert_eq!(
git_output(&repo, &["rev-parse", &fixture.branch]),
branch_head
);
assert_eq!(branch_head, fixture.head);
fs::write(&fixture.allow, "").expect("allow candidate preparation");
let output = release.wait_with_output().expect("wait for release");
assert!(
output.status.success(),
"release failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(&fixture.attempts).expect("candidate preparation attempt"),
"prepare\n"
);
assert!(!fixture.worktree.exists());
}
#[test]
fn failed_candidate_build_reuses_the_version_after_a_fix_merges() {
let repo = TestRepo::new();
prepare_candidate_release_repo(&repo);
let state = tempfile::tempdir().expect("release state");
let log_path = state.path().join("gh.log");
let gh_script =
write_gh_candidate_release_script(&log_path.to_string_lossy(), "failure-then-success");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("failed candidate must stop before tagging");
assert!(error
.to_string()
.contains("release workflow failed for v0.9.2"));
assert!(
git_output_bare(
&repo,
&["for-each-ref", "--format=%(refname)", "refs/tags/v0.9.2"],
)
.is_empty(),
"failed candidate must leave the version available"
);
assert!(
!git_output_bare(
&repo,
&[
"for-each-ref",
"--format=%(refname)",
"refs/heads/release-candidate/",
],
)
.is_empty(),
"failed candidate ref should remain inspectable"
);
fs::write(repo.path().join("repair.txt"), "repair candidate\n").expect("write repair");
git(&repo, &["add", "repair.txt"]);
git(&repo, &["commit", "-m", "Repair release candidate"]);
git(&repo, &["push", "origin", "HEAD"]);
let repaired_head = git_output(&repo, &["rev-parse", "HEAD"]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("merged fix should retry the unconsumed version");
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("expected a released receipt");
};
assert_eq!(receipt.tag, "v0.9.2");
assert_eq!(receipt.commit, repaired_head);
assert_eq!(
git_output_bare(&repo, &["rev-parse", "refs/tags/v0.9.2"]),
repaired_head
);
}
#[test]
fn release_run_rearms_a_dropped_auto_merge_for_the_exact_head() {
prove_release_merge_wait(false);
}
#[test]
fn release_run_waits_for_queued_integration_without_rearming() {
prove_release_merge_wait(true);
}
fn prove_release_merge_wait(queued: bool) {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
fs::write(repo.path().join("feature.txt"), "release me\n").unwrap();
git(&repo, &["add", "feature.txt"]);
git(&repo, &["commit", "-m", "Add release change"]);
git(&repo, &["push", "origin", "HEAD"]);
let head = git_output(&repo, &["rev-parse", "HEAD"]);
let state = tempfile::tempdir().expect("release state");
let log_path = state.path().join("gh.log");
let gh_script = write_gh_merge_wait_script(&log_path.to_string_lossy(), queued);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("release should re-arm and resume");
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("expected a released receipt");
};
assert_eq!(receipt.version, "0.9.2");
assert_eq!(receipt.tag, "v0.9.2");
assert_eq!(receipt.commit, head);
let log = fs::read_to_string(log_path).expect("read gh log");
assert!(log.contains("api graphql"));
if !queued {
assert!(log.contains(&format!(
"pr merge 1176 --squash --auto --match-head-commit {head}"
)));
}
}
#[test]
fn release_run_repairs_failed_checks_before_tagging() {
for awaiting_queue in [false, true] {
prove_release_repairs_failed_checks(awaiting_queue);
}
}
fn prove_release_repairs_failed_checks(awaiting_queue: bool) {
let merge_state = if awaiting_queue { "BEHIND" } else { "BLOCKED" };
let state = tempfile::tempdir().unwrap();
let repaired = state.path().join("repaired");
let checks = support::github_checks_page(
"$head",
&[
("tests-result", "FAILURE", true),
("swift-test", "FAILURE", false),
],
);
let open = support::github_merge_response(
1309,
"$head",
"OPEN",
merge_state,
Some(if awaiting_queue {
"awaiting_queue"
} else {
"auto"
}),
);
let merged = support::github_merge_response(1309, "$head", "MERGED", "UNKNOWN", None);
let gh = format!(
r#"#!/bin/sh
repaired='{}'
head=$(git rev-parse HEAD)
case "$1 $2" in
'--version ') echo 'gh fixture';;
'release view') exit 1;;
'run list') echo '[]';;
'pr list')
case " $* " in
*' --head '*) printf '[{{"number":1309,"state":"OPEN","mergeCommit":null,"url":"https://github.com/loopflowstudio/release-fixture/pull/1309","headRefOid":"%s"}}]\n' "$head";;
*) echo '[]';;
esac;;
'api graphql')
case "$*" in
*LoopflowPrChecks*)
cat <<JSON
{checks}
JSON
;;
*)
if [ -f "$repaired" ]; then
cat <<JSON
{merged}
JSON
else
cat <<JSON
{open}
JSON
fi ;;
esac;;
*) echo "unexpected gh: $*" >&2; exit 1;;
esac
"#,
repaired.display()
);
let codex = support::codex_app_server_script(
r#"{"status":"published","summary":"Reran the failed Swift job; the exact release head is green and merged."}"#,
&format!("touch '{}'", repaired.display()),
);
let _env = EnvGuard::new(&[("gh", &gh), ("codex", &codex)]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
fs::create_dir_all(repo.path().join(".lf")).unwrap();
fs::write(repo.path().join(".lf/config.yaml"), "agent: codex\n").unwrap();
fs::write(repo.path().join("feature.txt"), "release me").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-m", "Release fixture"]);
git(&repo, &["push", "origin", "HEAD"]);
let head = git_output(&repo, &["rev-parse", "HEAD"]);
let branch = format!(
"{}/release-default-v0-9-2",
loopflow::engine::naming::git_user(repo.path()).unwrap()
);
git(
&repo,
&["push", "origin", &format!("HEAD:refs/heads/{branch}")],
);
let local_remote = git_output(&repo, &["remote", "get-url", "origin"]);
let github_remote = "https://github.com/loopflowstudio/release-fixture.git";
git(
&repo,
&[
"config",
&format!("url.{local_remote}.insteadOf"),
github_remote,
],
);
git(&repo, &["remote", "set-url", "origin", github_remote]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress).unwrap();
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("release did not settle")
};
assert!(
repaired.exists(),
"the failed required check must receive repair"
);
assert_eq!(receipt.commit, head);
assert_eq!(
git_output_bare(&repo, &["rev-parse", "refs/tags/v0.9.2"]),
head
);
assert_eq!(
git_output(&repo, &["worktree", "list", "--porcelain"])
.lines()
.filter(|line| line.starts_with("worktree "))
.count(),
1
);
}
#[test]
fn release_run_reintegrates_a_dirty_existing_pr() {
let repo = TestRepo::new();
let main_branch = git_output(&repo, &["branch", "--show-current"]);
let state = tempfile::tempdir().expect("release state");
let log_path = state.path().join("gh.log");
let prepare_runs = state.path().join("prepare-runs");
let advance_once = state.path().join("advance-once");
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
fs::write(repo.path().join("feature.txt"), "release me\n").unwrap();
git(&repo, &["add", "feature.txt"]);
git(&repo, &["commit", "-m", "Add release change"]);
git(&repo, &["push", "origin", "HEAD"]);
let release_branch = "jack/release-default-v0-9-2";
git(&repo, &["checkout", "-b", release_branch]);
fs::write(
repo.path().join("RELEASE_NOTES.md"),
"# v0.9.2\n\nRelease notes.\n",
)
.unwrap();
git(&repo, &["add", "RELEASE_NOTES.md"]);
git(&repo, &["commit", "-m", "release: v0.9.2"]);
git(&repo, &["push", "-u", "origin", release_branch]);
git(&repo, &["checkout", &main_branch]);
git(&repo, &["branch", "-D", release_branch]);
git(
&repo,
&[
"update-ref",
"-d",
&format!("refs/remotes/origin/{release_branch}"),
],
);
fs::write(
repo.path().join("RELEASE_NOTES.md"),
"# v0.9.1\n\nCorrected prior release notes.\n",
)
.unwrap();
fs::create_dir_all(repo.path().join(".lf")).unwrap();
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n prepare:\n - sh prepare.sh {version}\n",
)
.unwrap();
fs::write(
repo.path().join("prepare.sh"),
format!(
"#!/bin/sh\nprintf 'run\\n' >> '{}'\nprintf '%s\\n' \"$1\" > prepared-version.txt\ngit -C '{}' rev-parse 'origin/{}' > prepared-main.txt\nif [ ! -f '{}' ]; then\n : > '{}'\n printf 'late main change\\n' > '{}/late-main.txt'\n git -C '{}' add late-main.txt\n git -C '{}' commit -m 'Advance main during release preparation'\n git -C '{}' push origin HEAD\nfi\n",
prepare_runs.display(),
repo.path().display(),
main_branch,
advance_once.display(),
advance_once.display(),
repo.path().display(),
repo.path().display(),
repo.path().display(),
repo.path().display(),
),
)
.unwrap();
git(
&repo,
&["add", "RELEASE_NOTES.md", ".lf/config.yaml", "prepare.sh"],
);
git(&repo, &["commit", "-m", "Correct prior release notes"]);
git(&repo, &["push", "origin", "HEAD"]);
let gh_script =
write_gh_dirty_release_script(&log_path.to_string_lossy(), release_branch, &main_branch);
let lf_script = "#!/bin/sh\ncat > RELEASE_NOTES.md <<'EOF'\n# v0.9.2\n\n<!-- loopflow:release-notes=narrative;gate=safe -->\n\nRegenerated release notes.\nEOF\n";
let _env = EnvGuard::new(&[("gh", gh_script.as_str()), ("lf", lf_script)]);
std::env::set_var("LF_RUN_ID", "run_stale");
std::env::set_var("LF_WAVE_ID", "wave_stale");
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.expect("release should reintegrate without inheriting Task authority");
let ReleaseRunOutcome::Released(receipt) = outcome else {
panic!("expected a released receipt");
};
let integrated_head = git_output_bare(
&repo,
&["rev-parse", &format!("refs/heads/{release_branch}")],
);
let integrated_parent = git_output_bare(&repo, &["rev-parse", &format!("{integrated_head}^")]);
assert_eq!(receipt.commit, integrated_head);
assert_eq!(
fs::read_to_string(&prepare_runs)
.expect("read preparation runs")
.lines()
.count(),
2,
"a main advance after preparation must rerun preparation"
);
assert_eq!(
git_output_bare(
&repo,
&["show", &format!("{integrated_head}:prepared-main.txt")],
),
integrated_parent
);
assert_eq!(
git_output_bare(
&repo,
&["show", &format!("{integrated_head}:RELEASE_NOTES.md")],
),
"# v0.9.2\n\n<!-- loopflow:release-notes=narrative;gate=safe -->\n\nRegenerated release notes."
);
assert_eq!(
git_output_bare(
&repo,
&["show", &format!("{integrated_head}:prepared-version.txt")],
),
"0.9.2"
);
let log = fs::read_to_string(log_path).expect("read gh log");
assert!(log.contains(&format!(
"pr merge 1176 --squash --auto --match-head-commit {integrated_head}"
)));
}
#[test]
fn release_run_fails_closed_when_the_publisher_role_is_missing() {
let gh_script = write_gh_script("[]");
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).expect("create config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n publisher: [\"missing-release-publisher-role\"]\n",
)
.expect("write config");
git(&repo, &["add", ".lf/config.yaml"]);
git(&repo, &["commit", "-m", "Require publisher role"]);
git(&repo, &["push", "origin", "HEAD"]);
git(&repo, &["tag", "v0.9.1"]);
let head_before = git_output(&repo, &["rev-parse", "HEAD"]);
let tags_before = git_output(&repo, &["tag", "--list"]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("missing publisher authority must stop release");
assert!(error.to_string().contains("missing-release-publisher-role"));
assert_eq!(git_output(&repo, &["rev-parse", "HEAD"]), head_before);
assert_eq!(git_output(&repo, &["tag", "--list"]), tags_before);
}
#[test]
fn release_run_refuses_to_skip_an_incomplete_tag() {
let _env = EnvGuard::new(&[("gh", write_gh_incomplete_release_script())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).expect("create config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n publisher: [\"true\"]\n",
)
.expect("write config");
git(&repo, &["tag", "v0.9.1"]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("incomplete tag should stop the release");
assert!(error.to_string().contains("v0.9.1 is incomplete"));
}
#[test]
fn release_run_keeps_a_failed_tag_red_until_a_fix_merges() {
let _env = EnvGuard::new(&[("gh", write_gh_failed_release_script())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).expect("create config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n publisher: [\"true\"]\n",
)
.expect("write config");
git(&repo, &["tag", "v0.9.1"]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("failed build without a fix should stay red");
assert!(error.to_string().contains("no merged fix is available"));
}
#[test]
fn active_tagged_publisher_blocks_concurrent_cleanup_until_exit() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
let state = tempfile::tempdir().expect("publisher state");
let ready = state.path().join("ready");
let publish = state.path().join("publish");
let completed = state.path().join("completed");
let exit = state.path().join("exit");
let published = state.path().join("published");
let publisher = repo.path().join("publisher.sh");
fs::write(
&publisher,
format!(
r#"#!/bin/sh
case "$1" in
check) exit 0 ;;
prepare)
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--output) output="$2"; shift 2 ;;
*) shift ;;
esac
done
mkdir -p "$output"
printf '{{}}\n' > "$output/candidate.json"
exit 0 ;;
publish)
worktree="$LF_RELEASE_SOURCE_REPO"
[ -e "$worktree/.git" ] || {{ echo 'tagged source missing' >&2; exit 41; }}
[ ! -f "$worktree/publisher.sh" ] || {{ echo 'publisher control came from tag' >&2; exit 42; }}
: > '{}'
attempts=0
while [ ! -f '{}' ] && [ "$attempts" -lt 500 ]; do
attempts=$((attempts + 1))
sleep 0.02
done
[ -d "$worktree" ] || {{ echo 'publisher worktree disappeared' >&2; exit 43; }}
: > '{}'
: > '{}'
attempts=0
while [ ! -f '{}' ] && [ "$attempts" -lt 500 ]; do
attempts=$((attempts + 1))
sleep 0.02
done
[ -d "$worktree" ] || {{ echo 'publisher worktree disappeared before exit' >&2; exit 44; }}
exit 0 ;;
esac
exit 2
"#,
ready.display(),
publish.display(),
published.display(),
completed.display(),
exit.display(),
),
)
.expect("write publisher");
let gh_script = format!(
r#"#!/bin/sh
if [ "$1" = "--version" ]; then
echo 'gh version 2.0.0'
exit 0
fi
case "$1 $2" in
'release view')
if [ -f '{}' ]; then
echo '{{"isDraft":false}}'
exit 0
fi
exit 1 ;;
'run list')
echo '[{{"databaseId":42,"headBranch":"v0.9.1","status":"completed","conclusion":"success"}}]'
exit 0 ;;
'run download') exit 0 ;;
esac
echo "unexpected gh invocation: $@" >&2
exit 1
"#,
published.display(),
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
fs::create_dir_all(repo.path().join(".lf")).expect("config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n publisher: [\"sh\", \"{repo}/publisher.sh\"]\n",
)
.expect("release config");
git(&repo, &["add", ".lf/config.yaml", "publisher.sh"]);
git(&repo, &["commit", "-m", "Add current publisher controller"]);
git(&repo, &["push", "origin", "HEAD"]);
let tags_before = git_output(&repo, &["tag", "--list"]);
let neighbor = repo.create_named_worktree("neighbor");
let repo_name = repo
.path()
.file_name()
.expect("repo name")
.to_string_lossy();
let publisher_worktree = repo
.path()
.parent()
.expect("repo parent")
.join(format!("{repo_name}.publish-default-v0-9-1"));
let release = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["release", "run", "patch"])
.current_dir(repo.path())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("start release re-entry");
wait_for_path(&ready);
assert!(publisher_worktree.exists());
let removal = Command::new(env!("CARGO_BIN_EXE_lf"))
.args(["wt", "delete", "publish-default-v0-9-1"])
.current_dir(repo.path())
.output()
.expect("attempt concurrent cleanup");
assert!(!removal.status.success());
assert!(
String::from_utf8_lossy(&removal.stderr).contains("release publisher for v0.9.1"),
"unexpected cleanup error: {}",
String::from_utf8_lossy(&removal.stderr)
);
assert!(publisher_worktree.exists());
assert!(neighbor.exists());
fs::write(&publish, "").expect("allow publication");
wait_for_path(&completed);
assert!(
publisher_worktree.exists(),
"owner cleanup must wait for publisher exit"
);
fs::write(&exit, "").expect("allow publisher exit");
let output = release
.wait_with_output()
.expect("wait for release re-entry");
assert!(
output.status.success(),
"release re-entry failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!publisher_worktree.exists());
assert!(neighbor.exists());
assert_eq!(git_output(&repo, &["tag", "--list"]), tags_before);
}
#[derive(Debug, Clone, Copy)]
enum InterruptedWorktree {
EmptyPath,
BranchOnly,
MissingPath,
Complete,
}
#[derive(Debug, Clone, Copy)]
enum PublisherRegistrationConflict {
BranchElsewhere,
PathOnOtherBranch,
}
struct CandidatePublisherFixture {
head: String,
worktree_name: String,
worktree: PathBuf,
branch: String,
gh_script: String,
attempts: PathBuf,
block: PathBuf,
ready: PathBuf,
allow: PathBuf,
}
fn configure_candidate_publisher(
repo: &TestRepo,
state: &std::path::Path,
) -> CandidatePublisherFixture {
prepare_candidate_release_repo(repo);
let log_path = state.join("gh.log");
let attempts = state.join("preparation-attempts");
let block = state.join("block-preparation");
let ready = state.join("preparation-ready");
let allow = state.join("allow-preparation");
let publisher = repo.path().join("publisher.sh");
fs::write(
&publisher,
format!(
r#"#!/bin/sh
case "$1" in
check) exit 0 ;;
prepare)
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--output) output="$2"; shift 2 ;;
*) shift ;;
esac
done
expected="$(git -C "$LF_RELEASE_MAIN_REPO" rev-parse 'HEAD^{{commit}}')"
observed="$(git rev-parse 'HEAD^{{commit}}')"
prefix="$(printf '%s' "$expected" | cut -c1-9)"
branch="$(git symbolic-ref --short HEAD)"
[ "$observed" = "$expected" ] || {{ echo "preparation commit mismatch: $observed" >&2; exit 71; }}
[ "$branch" = "jack/prepare-default-v0-9-2-$prefix" ] || {{ echo "preparation branch mismatch: $branch" >&2; exit 72; }}
printf 'prepare\n' >> '{}'
if [ -f '{}' ]; then
: > '{}'
waits=0
while [ ! -f '{}' ] && [ "$waits" -lt 500 ]; do
waits=$((waits + 1))
sleep 0.02
done
[ -f '{}' ] || {{ echo 'candidate preparation timed out' >&2; exit 73; }}
fi
mkdir -p "$output"
printf '{{}}\n' > "$output/candidate.json"
exit 0 ;;
publish)
: > '{}.published'
exit 0 ;;
esac
exit 2
"#,
attempts.display(),
block.display(),
ready.display(),
allow.display(),
allow.display(),
log_path.display(),
),
)
.expect("write publisher");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n workflow: release.yml\n publisher: [\"sh\", \"{repo}/publisher.sh\"]\n",
)
.expect("configure publisher");
git(repo, &["add", ".lf/config.yaml", "publisher.sh"]);
git(repo, &["commit", "-m", "Configure candidate publisher"]);
git(repo, &["push", "origin", "HEAD"]);
let head = git_output(repo, &["rev-parse", "HEAD"]);
let revision = head.get(..9).expect("full commit hash");
let worktree_name = format!("prepare-default-v0-9-2-{revision}");
let worktree = named_worktree_path(repo, &worktree_name);
let branch = format!("jack/{worktree_name}");
let gh_script = write_gh_candidate_release_script(&log_path.to_string_lossy(), "success");
CandidatePublisherFixture {
head,
worktree_name,
worktree,
branch,
gh_script,
attempts,
block,
ready,
allow,
}
}
fn named_worktree_path(repo: &TestRepo, name: &str) -> PathBuf {
let repo_name = repo
.path()
.file_name()
.expect("repo name")
.to_string_lossy();
repo.path()
.parent()
.expect("repo parent")
.join(format!("{repo_name}.{name}"))
}
fn publisher_worktree_path(repo: &TestRepo) -> PathBuf {
named_worktree_path(repo, "publish-default-v0-9-1")
}
fn leave_interrupted_worktree(
repo: &TestRepo,
state: InterruptedWorktree,
name: &str,
branch: &str,
revision: &str,
) -> PathBuf {
let path = named_worktree_path(repo, name);
if matches!(state, InterruptedWorktree::EmptyPath) {
fs::create_dir(&path).expect("leave empty worktree path");
return path;
}
let path_arg = path.to_string_lossy();
git(
repo,
&["worktree", "add", "-b", branch, &path_arg, revision],
);
match state {
InterruptedWorktree::BranchOnly => {
git(repo, &["worktree", "remove", "--force", &path_arg]);
}
InterruptedWorktree::MissingPath => {
fs::remove_dir_all(&path).expect("remove interrupted worktree body");
}
InterruptedWorktree::Complete => {}
InterruptedWorktree::EmptyPath => unreachable!(),
}
path
}
fn leave_interrupted_publisher_tree(repo: &TestRepo, state: InterruptedWorktree) -> PathBuf {
leave_interrupted_worktree(
repo,
state,
"publish-default-v0-9-1",
"jack/publish-default-v0-9-1",
"v0.9.1",
)
}
fn configure_same_tag_publisher(repo: &TestRepo, state: &std::path::Path) -> String {
let published = state.join("published");
let attempts = state.join("attempts");
let publisher = repo.path().join("publisher.sh");
fs::write(
&publisher,
format!(
r#"#!/bin/sh
case "$1" in
check) exit 0 ;;
prepare)
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--output) output="$2"; shift 2 ;;
*) shift ;;
esac
done
mkdir -p "$output"
printf '{{}}\n' > "$output/candidate.json"
exit 0 ;;
publish)
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--artifacts) artifacts="$2"; shift 2 ;;
*) shift ;;
esac
done
expected="$(git -C "$LF_RELEASE_MAIN_REPO" rev-parse 'v0.9.1^{{commit}}')"
observed="$(git rev-parse 'HEAD^{{commit}}')"
branch="$(git symbolic-ref --short HEAD)"
[ "$observed" = "$expected" ] || {{ echo "publisher commit mismatch: $observed" >&2; exit 71; }}
[ "$branch" = jack/publish-default-v0-9-1 ] || {{ echo "publisher branch mismatch: $branch" >&2; exit 72; }}
[ -f "$artifacts/candidate.json" ] || {{ echo "verified candidate missing: $artifacts" >&2; exit 73; }}
case "$artifacts" in
*/.lf/releases/v0-9-1/"$expected"-42) ;;
*) echo "candidate ownership mismatch: $artifacts" >&2; exit 74 ;;
esac
printf 'publish\n' >> '{}'
: > '{}'
exit 0 ;;
esac
exit 2
"#,
attempts.display(),
published.display(),
),
)
.expect("write publisher");
fs::create_dir_all(repo.path().join(".lf")).expect("config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
"release:\n targets:\n default:\n publisher: [\"sh\", \"{repo}/publisher.sh\"]\n",
)
.expect("release config");
git(repo, &["add", ".lf/config.yaml", "publisher.sh"]);
git(repo, &["commit", "-m", "Configure current publisher"]);
git(repo, &["push", "origin", "HEAD"]);
format!(
r#"#!/bin/sh
if [ "$1" = "--version" ]; then exit 0; fi
case "$1 $2" in
'release view')
[ -f '{}' ] || exit 1
echo '{{"isDraft":false}}'
exit 0 ;;
'run list')
echo '[{{"databaseId":42,"headBranch":"v0.9.1","status":"completed","conclusion":"success"}}]'
exit 0 ;;
'run download') exit 0 ;;
esac
echo "unexpected gh invocation: $*" >&2
exit 1
"#,
published.display()
)
}
#[test]
fn same_tag_release_retry_owns_each_interrupted_publisher_materialization() {
for interrupted in [
InterruptedWorktree::EmptyPath,
InterruptedWorktree::BranchOnly,
InterruptedWorktree::MissingPath,
InterruptedWorktree::Complete,
] {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
let state = tempfile::tempdir().expect("publisher state");
let gh_script = configure_same_tag_publisher(&repo, state.path());
let publisher_worktree = leave_interrupted_publisher_tree(&repo, interrupted);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let outcome = release_run(repo.path(), "patch", None, &NullProgress)
.unwrap_or_else(|error| panic!("{interrupted:?} retry failed: {error}"));
let ReleaseRunOutcome::Resumed(receipt) = outcome else {
panic!("{interrupted:?} should resume the tagged release");
};
assert_eq!(receipt.tag, "v0.9.1");
assert_eq!(
fs::read_to_string(state.path().join("attempts")).expect("publication attempt"),
"publish\n"
);
assert!(state.path().join("published").exists());
assert!(!publisher_worktree.exists());
}
}
#[test]
fn same_tag_release_retry_preserves_dirty_publisher_worktree() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
let state = tempfile::tempdir().expect("publisher state");
let gh_script = configure_same_tag_publisher(&repo, state.path());
let publisher_worktree = leave_interrupted_publisher_tree(&repo, InterruptedWorktree::Complete);
fs::write(publisher_worktree.join("operator.txt"), "preserve me\n")
.expect("dirty publisher tree");
let tag_commit = git_output(&repo, &["rev-parse", "v0.9.1^{commit}"]);
let branch_head = git_output(
&repo,
&["rev-parse", "jack/publish-default-v0-9-1^{commit}"],
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("dirty publisher state must fail closed");
let message = error.to_string();
assert!(message.contains(&publisher_worktree.display().to_string()));
assert!(message.contains("jack/publish-default-v0-9-1"));
assert!(message.contains(&tag_commit));
assert!(message.contains("operator.txt"));
assert!(message.contains("no state was changed"));
assert_eq!(
fs::read_to_string(publisher_worktree.join("operator.txt")).expect("preserved file"),
"preserve me\n"
);
assert_eq!(
git_output(
&repo,
&["rev-parse", "jack/publish-default-v0-9-1^{commit}"],
),
branch_head
);
assert!(!state.path().join("attempts").exists());
}
#[test]
fn same_tag_release_retry_preserves_mismatched_publisher_worktree() {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
let state = tempfile::tempdir().expect("publisher state");
let gh_script = configure_same_tag_publisher(&repo, state.path());
let publisher_worktree = publisher_worktree_path(&repo);
let path_arg = publisher_worktree.to_string_lossy();
git(
&repo,
&[
"worktree",
"add",
"-b",
"jack/publish-default-v0-9-1",
&path_arg,
"main",
],
);
let tag_commit = git_output(&repo, &["rev-parse", "v0.9.1^{commit}"]);
let branch_head = git_output(
&repo,
&["rev-parse", "jack/publish-default-v0-9-1^{commit}"],
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let error = release_run(repo.path(), "patch", None, &NullProgress)
.expect_err("mismatched publisher state must fail closed");
let message = error.to_string();
assert!(message.contains(&publisher_worktree.display().to_string()));
assert!(message.contains(&tag_commit));
assert!(message.contains(&branch_head));
assert!(message.contains("no state was changed"));
assert!(publisher_worktree.exists());
assert_eq!(
git_output(
&repo,
&["rev-parse", "jack/publish-default-v0-9-1^{commit}"],
),
branch_head
);
assert!(!state.path().join("attempts").exists());
}
#[test]
fn same_tag_release_retry_preserves_differently_registered_publisher_worktrees() {
for conflict in [
PublisherRegistrationConflict::BranchElsewhere,
PublisherRegistrationConflict::PathOnOtherBranch,
] {
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
git(&repo, &["push", "origin", "v0.9.1"]);
let state = tempfile::tempdir().expect("publisher state");
let gh_script = configure_same_tag_publisher(&repo, state.path());
let publisher_worktree = publisher_worktree_path(&repo);
let expected_branch = "jack/publish-default-v0-9-1";
let (registered_path, registered_branch) = match conflict {
PublisherRegistrationConflict::BranchElsewhere => (
named_worktree_path(&repo, "publisher-registered-elsewhere"),
expected_branch,
),
PublisherRegistrationConflict::PathOnOtherBranch => {
(publisher_worktree.clone(), "jack/operator-publisher-state")
}
};
let path_arg = registered_path.to_string_lossy();
git(
&repo,
&[
"worktree",
"add",
"-b",
registered_branch,
&path_arg,
"v0.9.1",
],
);
let tag_commit = git_output(&repo, &["rev-parse", "v0.9.1^{commit}"]);
let registered_head = git_output(
&repo,
&["rev-parse", &format!("{registered_branch}^{{commit}}")],
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let error = match release_run(repo.path(), "patch", None, &NullProgress) {
Err(error) => error,
Ok(_) => panic!("{conflict:?} should fail closed"),
};
let message = error.to_string();
assert!(message.contains(&publisher_worktree.display().to_string()));
assert!(message.contains(expected_branch));
assert!(message.contains(&tag_commit));
assert!(message.contains(®istered_path.display().to_string()));
assert!(message.contains(registered_branch));
assert!(message.contains("no state was changed"));
assert!(registered_path.exists());
assert_eq!(
git_output(
&repo,
&["rev-parse", &format!("{registered_branch}^{{commit}}")],
),
registered_head
);
assert!(!state.path().join("attempts").exists());
}
}
#[test]
fn release_bump_updates_cargo_toml() {
let repo = TestRepo::new();
fs::write(
repo.path().join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.9.0\"\n",
)
.unwrap();
release_bump(repo.path(), "0.9.1", None, &NullProgress).expect("bump should succeed");
let content = fs::read_to_string(repo.path().join("Cargo.toml")).unwrap();
assert!(content.contains("version = \"0.9.1\""));
assert!(!content.contains("version = \"0.9.0\""));
}
#[test]
fn release_status_reports_latest_tag_and_release() {
let gh_script = write_gh_status_script(
r#"[{"databaseId":7,"headBranch":"main","displayTitle":"Retry v0.9.1","status":"completed","conclusion":"failure","url":"https://example.com/run/7"},{"databaseId":42,"headBranch":"v0.9.1","displayTitle":"Release v0.9.1","status":"completed","conclusion":"success","url":"https://example.com/run/42"}]"#,
r#"{"isDraft":false}"#,
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
let status = release_status(repo.path(), None).expect("status should succeed");
assert_eq!(status.target, "default");
assert_eq!(status.latest_tag.as_deref(), Some("v0.9.1"));
assert_eq!(status.workflow_status.as_deref(), Some("completed"));
assert_eq!(status.workflow_conclusion.as_deref(), Some("success"));
assert_eq!(status.notes_status, Some(ReleaseNotesStatus::Missing));
assert!(status.release_exists);
}
#[test]
fn release_status_reports_degraded_notes_as_gate_safe() {
let gh_script = write_gh_status_script(
r#"[{"databaseId":42,"headBranch":"v0.9.1","status":"completed","conclusion":"success"}]"#,
r#"{"isDraft":false}"#,
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join("release/v0.9.1")).unwrap();
fs::write(
repo.path().join("release/v0.9.1/NOTES.md"),
"# v0.9.1\n\n<!-- loopflow:release-notes=degraded;reason=quota;gate=safe -->\n\nConcise fallback notes.\n",
)
.unwrap();
git(&repo, &["add", "release/v0.9.1/NOTES.md"]);
git(&repo, &["commit", "-m", "Archive degraded release notes"]);
git(&repo, &["tag", "v0.9.1"]);
let status = release_status(repo.path(), None).expect("status should read tagged notes");
assert_eq!(
status.notes_status,
Some(ReleaseNotesStatus::Degraded(ReleaseNotesDegradation::Quota))
);
assert!(status.release_exists);
}
#[test]
fn release_status_reports_unmarked_notes_as_legacy() {
let gh_script = write_gh_status_script("[]", r#"{"isDraft":false}"#);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join("release/v0.9.1")).unwrap();
fs::write(
repo.path().join("release/v0.9.1/NOTES.md"),
"# v0.9.1\n\nNotes from before status markers.\n",
)
.unwrap();
git(&repo, &["add", "release/v0.9.1/NOTES.md"]);
git(&repo, &["commit", "-m", "Archive legacy release notes"]);
git(&repo, &["tag", "v0.9.1"]);
let status = release_status(repo.path(), None).expect("status should read legacy notes");
assert_eq!(status.notes_status, Some(ReleaseNotesStatus::Legacy));
assert!(status.release_exists);
}
#[test]
fn release_status_does_not_trust_malformed_safe_markers() {
let gh_script = write_gh_status_script("[]", r#"{"isDraft":false}"#);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let markers = [
"narrative;gate=unsafe -->",
"degraded;reason=quota -->",
"degraded;reason=unknown;gate=safe -->",
];
for marker in markers {
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join("release/v0.9.1")).unwrap();
fs::write(
repo.path().join("release/v0.9.1/NOTES.md"),
format!("# v0.9.1\n\n<!-- loopflow:release-notes={marker}\n\nNotes.\n"),
)
.unwrap();
git(&repo, &["add", "release/v0.9.1/NOTES.md"]);
git(&repo, &["commit", "-m", "Archive malformed release notes"]);
git(&repo, &["tag", "v0.9.1"]);
let status = release_status(repo.path(), None).expect("status should fail closed");
assert_eq!(status.notes_status, Some(ReleaseNotesStatus::Legacy));
}
}
#[test]
fn release_status_does_not_count_a_draft_as_complete() {
let gh_script = write_gh_status_script(
r#"[{"databaseId":42,"headBranch":"v0.9.1","status":"completed","conclusion":"success"}]"#,
r#"{"isDraft":true}"#,
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
git(&repo, &["tag", "v0.9.1"]);
let status = release_status(repo.path(), None).expect("status should succeed");
assert!(!status.release_exists);
}
#[test]
fn release_status_scopes_to_named_target() {
let gh_script = write_gh_status_script(
r#"[{"databaseId":7,"headBranch":"cli/v1.2.3","displayTitle":"Release cli/v1.2.3","status":"completed","conclusion":"success","url":"https://example.com/run/7"}]"#,
r#"{"isDraft":false}"#,
);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
fs::create_dir_all(repo.path().join(".lf")).expect("create config dir");
fs::write(
repo.path().join(".lf/config.yaml"),
r#"
release:
targets:
cli:
tag_prefix: "cli/"
"#,
)
.expect("write config");
git(&repo, &["tag", "cli/v1.2.3"]);
let status = release_status(repo.path(), Some("cli")).expect("status should succeed");
assert_eq!(status.target, "cli");
assert_eq!(status.latest_tag.as_deref(), Some("cli/v1.2.3"));
assert!(status.release_exists);
}
#[test]
fn release_tag_is_idempotent_when_remote_tag_matches_head() {
let repo = TestRepo::new();
let head = git_output(&repo, &["rev-parse", "HEAD"]);
let tag = release_tag(repo.path(), "0.9.1", None).expect("first tag should succeed");
assert_eq!(tag, "v0.9.1");
let second = release_tag(repo.path(), "0.9.1", None).expect("second tag should be no-op");
assert_eq!(second, "v0.9.1");
let remote_tag = git_output_bare(&repo, &["rev-parse", "refs/tags/v0.9.1"]);
assert_eq!(remote_tag, head);
}
#[test]
fn release_run_resumes_an_existing_explicit_tag() {
let gh_script = write_gh_status_script("[]", r#"{"isDraft":false}"#);
let _env = EnvGuard::new(&[("gh", gh_script.as_str())]);
let repo = TestRepo::new();
let intended_notes = "# v0.9.1\n\n<!-- loopflow:release-notes=narrative;gate=safe -->\n\nConcise intended artifact.\n";
fs::create_dir_all(repo.path().join("release/v0.9.1")).unwrap();
fs::write(repo.path().join("release/v0.9.1/NOTES.md"), intended_notes).unwrap();
git(&repo, &["add", "release/v0.9.1/NOTES.md"]);
git(&repo, &["commit", "-m", "Prepare exact release artifact"]);
release_tag(repo.path(), "0.9.1", None).expect("tag should succeed");
let result = release_run(repo.path(), "0.9.1", None, &NullProgress)
.expect("interrupted release should resume from its tag");
let ReleaseRunOutcome::Resumed(receipt) = result else {
panic!("expected resumed release");
};
assert_eq!(receipt.version, "0.9.1");
assert_eq!(receipt.tag, "v0.9.1");
assert!(receipt.release_exists);
let status = release_status(repo.path(), None).expect("status should preserve narrative notes");
assert_eq!(status.notes_status, Some(ReleaseNotesStatus::Narrative));
assert_eq!(
git_output_bare(&repo, &["show", "v0.9.1:release/v0.9.1/NOTES.md",],),
intended_notes.trim()
);
assert_eq!(
git_output_bare(
&repo,
&["for-each-ref", "--format=%(refname)", "refs/tags/v0.9.1"],
)
.lines()
.count(),
1
);
}
#[test]
fn release_tag_fails_if_remote_tag_points_to_different_commit() {
let repo = TestRepo::new();
release_tag(repo.path(), "0.9.1", None).expect("initial tag should succeed");
repo.create_file("CHANGELOG.md", "new release prep");
repo.stage_all();
repo.commit("change after tag");
let err = release_tag(repo.path(), "0.9.1", None).expect_err("mismatched tag should fail");
let message = err.to_string();
assert!(message.contains("already exists on origin"));
assert!(message.contains("expected"));
}