From 906d0bd36afbd576cf0f089f3280c23daa7fa366 Mon Sep 17 00:00:00 2001 From: graysurf <10785178+graysurf@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:55:43 +0800 Subject: [PATCH] feat(release-workflow): guard publish against unsynced upstream --- skills/automation/release-workflow/SKILL.md | 4 +- .../references/DEFAULT_RELEASE_GUIDE.md | 5 +- .../scripts/release-publish-from-changelog.sh | 77 +++- .../tests/test_automation_release_workflow.py | 1 + .../release-publish-from-changelog.sh.json | 2 +- tests/test_script_smoke_release_workflow.py | 348 ++++++++++++++---- 6 files changed, 351 insertions(+), 86 deletions(-) diff --git a/skills/automation/release-workflow/SKILL.md b/skills/automation/release-workflow/SKILL.md index 797c22d..d55b20b 100644 --- a/skills/automation/release-workflow/SKILL.md +++ b/skills/automation/release-workflow/SKILL.md @@ -72,10 +72,10 @@ Use only these public entrypoints: - Resolve the guide + template deterministically: - `$AGENT_HOME/skills/automation/release-workflow/scripts/release-resolve.sh --repo .` -- Publish GitHub release from changelog via single entrypoint (extract + audit + create/edit + verify body): +- Publish GitHub release from changelog via single entrypoint (extract + audit + upstream-sync check/push + create/edit + verify body): ```bash - $AGENT_HOME/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh --repo . --version v1.3.2 + $AGENT_HOME/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh --repo . --version v1.3.2 --push-current-branch ``` ## Migration notes (removed entrypoints) diff --git a/skills/automation/release-workflow/references/DEFAULT_RELEASE_GUIDE.md b/skills/automation/release-workflow/references/DEFAULT_RELEASE_GUIDE.md index 15bbce3..9576d44 100644 --- a/skills/automation/release-workflow/references/DEFAULT_RELEASE_GUIDE.md +++ b/skills/automation/release-workflow/references/DEFAULT_RELEASE_GUIDE.md @@ -7,6 +7,7 @@ Use this guide only when the target repository does not provide its own release - Run in the target repo root. - Working tree is clean: `git status -sb` - On the target branch (default: `main`) +- Current branch tracks an upstream and is publishable (`git status -sb` should not show ahead/behind drift after the release commit is pushed) - GitHub CLI is authenticated (when publishing GitHub Releases): `gh auth status` ## Steps @@ -29,8 +30,8 @@ Use this guide only when the target repository does not provide its own release - Commit message should match the repo’s conventions (if any). 5. Publish the GitHub release from the changelog entry - - Use the single entrypoint script (extract notes + audit + create/edit + non-empty body verification): - - `$AGENT_HOME/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh --repo . --version vX.Y.Z` + - Use the single entrypoint script (extract notes + audit + current-branch push when needed + create/edit + non-empty body verification): + - `$AGENT_HOME/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh --repo . --version vX.Y.Z --push-current-branch` 6. Verify the release - `gh release view vX.Y.Z` diff --git a/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh b/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh index 0a5ac2c..6c56d38 100755 --- a/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh +++ b/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh @@ -17,10 +17,12 @@ require_cmd() { usage() { cat >&2 <<'EOF' Usage: - release-publish-from-changelog.sh --version [--repo ] [--changelog ] [--notes-output ] [--title ] [--if-exists ] [--no-verify-body] + release-publish-from-changelog.sh --version [--repo ] [--changelog ] [--notes-output ] [--title ] [--if-exists ] [--push-current-branch] [--no-verify-body] Behavior: - Extracts release notes from CHANGELOG.md for --version. + - Requires a clean git work tree on a checked-out branch with a configured upstream. + - Fails when HEAD is not synced to upstream unless --push-current-branch is set. - Creates the release when missing. - Edits the release when it already exists (default behavior). - Verifies the published release body is non-empty unless --no-verify-body is set. @@ -41,6 +43,65 @@ notes_output="" title="" if_exists="edit" verify_body=1 +push_current_branch=0 + +ensure_git_repo() { + git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "repo is not a git work tree: $repo" +} + +ensure_clean_worktree() { + local status_output='' + status_output="$(git status --porcelain 2>/dev/null || true)" + if [[ -n "$status_output" ]]; then + die "working tree must be clean before publishing (commit or stash changes first)" + fi +} + +require_publishable_head() { + local current_branch='' + local head_sha='' + local upstream_ref='' + local counts='' + local ahead_count='0' + local behind_count='0' + local upstream_remote='' + local upstream_branch='' + + current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [[ -n "$current_branch" ]] || die "detached HEAD: checkout a branch before publishing" + + head_sha="$(git rev-parse HEAD 2>/dev/null || true)" + [[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || die "unable to resolve HEAD commit" + + upstream_ref="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)" + [[ -n "$upstream_ref" ]] || die "current branch has no upstream: $current_branch (push with --set-upstream before publishing)" + [[ "$upstream_ref" == */* ]] || die "unsupported upstream ref format: $upstream_ref" + + counts="$(git rev-list --left-right --count "HEAD...$upstream_ref" 2>/dev/null || true)" + [[ -n "$counts" ]] || die "unable to compare HEAD with upstream $upstream_ref" + read -r ahead_count behind_count <<<"$counts" + [[ "$ahead_count" =~ ^[0-9]+$ ]] || die "unable to parse ahead count for $upstream_ref" + [[ "$behind_count" =~ ^[0-9]+$ ]] || die "unable to parse behind count for $upstream_ref" + + if [[ "$behind_count" -gt 0 && "$ahead_count" -gt 0 ]]; then + die "current branch diverged from $upstream_ref (ahead=$ahead_count behind=$behind_count); reconcile before publishing" + fi + if [[ "$behind_count" -gt 0 ]]; then + die "current branch is behind $upstream_ref by $behind_count commit(s); pull/rebase before publishing" + fi + if [[ "$ahead_count" -gt 0 ]]; then + if [[ "$push_current_branch" -ne 1 ]]; then + die "current branch is ahead of $upstream_ref by $ahead_count commit(s); push first or rerun with --push-current-branch" + fi + + upstream_remote="${upstream_ref%%/*}" + upstream_branch="${upstream_ref#*/}" + info "pushing $current_branch to $upstream_ref before publishing" + git push "$upstream_remote" "HEAD:${upstream_branch}" >/dev/null + fi + + printf "%s\n" "$head_sha" +} while [[ $# -gt 0 ]]; do case "${1:-}" in @@ -68,6 +129,10 @@ while [[ $# -gt 0 ]]; do if_exists="${2:-}" shift 2 ;; + --push-current-branch) + push_current_branch=1 + shift + ;; --no-verify-body) verify_body=0 shift @@ -92,11 +157,16 @@ fi cd "$repo" || die "unable to cd: $repo" require_cmd gh +require_cmd git require_cmd awk +ensure_git_repo +ensure_clean_worktree if [[ ! -f "$changelog" ]]; then die "changelog not found: $changelog" fi +head_sha="$(require_publishable_head)" + if [[ -z "$notes_output" ]]; then agent_home="${AGENT_HOME:-}" if [[ -n "$agent_home" ]]; then @@ -134,8 +204,9 @@ fi mv -f -- "$tmp_notes" "$notes_output" trap - EXIT notes_file="$notes_output" +backticked_ref_pattern=$'`#[0-9]+`' -if grep -Eq '`#[0-9]+`' "$notes_file"; then +if grep -Eq "$backticked_ref_pattern" "$notes_file"; then die "backticked issue/PR reference detected in release notes (use plain #123)" fi if grep -Eq '\.\.\.' "$notes_file"; then @@ -159,7 +230,7 @@ if [[ "$release_exists" -eq 1 ]]; then gh release edit "$version" --title "$title" --notes-file "$notes_file" >/dev/null else info "creating release $version" - gh release create "$version" -F "$notes_file" --title "$title" >/dev/null + gh release create "$version" -F "$notes_file" --title "$title" --target "$head_sha" >/dev/null fi if (( verify_body )); then diff --git a/skills/automation/release-workflow/tests/test_automation_release_workflow.py b/skills/automation/release-workflow/tests/test_automation_release_workflow.py index 13d4685..b2871a8 100644 --- a/skills/automation/release-workflow/tests/test_automation_release_workflow.py +++ b/skills/automation/release-workflow/tests/test_automation_release_workflow.py @@ -26,4 +26,5 @@ def test_automation_release_workflow_declares_retained_entrypoints() -> None: assert "## Entrypoints (fallback helper scripts)" in text assert "$AGENT_HOME/skills/automation/release-workflow/scripts/release-resolve.sh" in text assert "$AGENT_HOME/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh" in text + assert "--push-current-branch" in text assert "legacy wrapper paths are not supported" in text.lower() diff --git a/tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json b/tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json index 5b8bf84..0eec927 100644 --- a/tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json +++ b/tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json @@ -6,7 +6,7 @@ "timeout_sec": 5, "expect": { "exit_codes": [0], - "stderr_regex": "^Usage:" + "stderr_regex": "(?s)^Usage:.*--push-current-branch" } } ] diff --git a/tests/test_script_smoke_release_workflow.py b/tests/test_script_smoke_release_workflow.py index b5d892e..cb852db 100644 --- a/tests/test_script_smoke_release_workflow.py +++ b/tests/test_script_smoke_release_workflow.py @@ -2,6 +2,7 @@ import json import os +import subprocess from pathlib import Path import pytest @@ -16,94 +17,42 @@ def write_executable(path: Path, content: str) -> None: path.chmod(0o755) -@pytest.mark.script_smoke -def test_script_smoke_release_and_ci_specs_match_retained_entrypoints() -> None: - repo = repo_root() +def git(cmd: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", *cmd], cwd=str(cwd), check=True, text=True, capture_output=True) - expected_specs = [ - "tests/script_specs/skills/automation/gh-fix-ci/scripts/gh-fix-ci.sh.json", - "tests/script_specs/skills/automation/gh-fix-ci/scripts/inspect_ci_checks.py.json", - "tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json", - "tests/script_specs/skills/automation/release-workflow/scripts/release-resolve.sh.json", - ] - spec_roots = [ - repo / "tests" / "script_specs" / "skills" / "automation" / "gh-fix-ci" / "scripts", - repo / "tests" / "script_specs" / "skills" / "automation" / "release-workflow" / "scripts", - ] - discovered_specs = sorted( - str(path.relative_to(repo)) - for root in spec_roots - for path in root.glob("*.json") - ) - assert discovered_specs == expected_specs - expected_scripts = [ - "skills/automation/gh-fix-ci/scripts/gh-fix-ci.sh", - "skills/automation/gh-fix-ci/scripts/inspect_ci_checks.py", - "skills/automation/release-workflow/scripts/release-publish-from-changelog.sh", - "skills/automation/release-workflow/scripts/release-resolve.sh", - ] - for script_path in expected_scripts: - assert (repo / script_path).is_file(), script_path +def init_release_fixture_repo(tmp_path: Path, *, default_branch: str = "main") -> tuple[Path, Path]: + work_tree = tmp_path / "release" + origin = tmp_path / "origin.git" + work_tree.mkdir(parents=True, exist_ok=True) + origin.mkdir(parents=True, exist_ok=True) - expected_case_names = { - "tests/script_specs/skills/automation/gh-fix-ci/scripts/gh-fix-ci.sh.json": {"help-surface"}, - "tests/script_specs/skills/automation/gh-fix-ci/scripts/inspect_ci_checks.py.json": {"help"}, - "tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json": { - "help-surface" - }, - "tests/script_specs/skills/automation/release-workflow/scripts/release-resolve.sh.json": {"json-defaults"}, - } - for spec_path, expected_names in expected_case_names.items(): - payload = json.loads((repo / spec_path).read_text("utf-8")) - smoke_cases = payload.get("smoke", []) - names = {case.get("name") for case in smoke_cases if isinstance(case, dict)} - assert names == expected_names, spec_path + git(["init"], cwd=work_tree) + git(["config", "user.email", "fixture@example.com"], cwd=work_tree) + git(["config", "user.name", "Fixture User"], cwd=work_tree) + git(["checkout", "-b", default_branch], cwd=work_tree) + (work_tree / "README.md").write_text("fixture\n", "utf-8") + git(["add", "README.md"], cwd=work_tree) + git(["commit", "-m", "init"], cwd=work_tree) -@pytest.mark.script_smoke -def test_script_smoke_release_publish_from_changelog(tmp_path: Path): - work_dir = tmp_path / "release" - work_dir.mkdir(parents=True, exist_ok=True) + git(["init", "--bare"], cwd=origin) + git(["remote", "add", "origin", str(origin)], cwd=work_tree) + git(["push", "-u", "origin", default_branch], cwd=work_tree) - version = "v1.2.3" - changelog = work_dir / "CHANGELOG.md" - changelog.write_text( - "\n".join( - [ - "# Changelog", - "", - "All notable changes to this project will be documented in this file.", - "", - "## v1.2.3 - 2026-01-01", - "", - "### Added", - "", - "- Added a thing", - "", - "### Changed", - "", - "- Changed a thing", - "", - "### Fixed", - "", - "- Fixed a thing", - "", - ] - ) - + "\n", - "utf-8", - ) + return (work_tree, origin) - fixture_bin = tmp_path / "bin" + +def write_release_gh_fixture(path: Path) -> None: write_executable( - fixture_bin / "gh", + path, "\n".join( [ "#!/usr/bin/env bash", "set -euo pipefail", 'state_dir="${GH_STUB_STATE_DIR:-.gh-state}"', "mkdir -p \"$state_dir\"", + "printf \"gh %s\\n\" \"$*\" >>\"${state_dir}/gh.calls.txt\"", "", 'if [[ "${1:-}" == "auth" && "${2:-}" == "status" ]]; then', " exit 0", @@ -137,6 +86,7 @@ def test_script_smoke_release_publish_from_changelog(tmp_path: Path): ' tag="${3:-}"', " shift 3", " notes_file=''", + " target=''", " while [[ $# -gt 0 ]]; do", ' case "${1:-}" in', " -F|--notes-file)", @@ -146,6 +96,10 @@ def test_script_smoke_release_publish_from_changelog(tmp_path: Path): " --title|-t)", " shift 2", " ;;", + " --target)", + ' target="${2:-}"', + " shift 2", + " ;;", " *)", " shift", " ;;", @@ -153,6 +107,7 @@ def test_script_smoke_release_publish_from_changelog(tmp_path: Path): " done", ' [[ -n "$notes_file" ]] || exit 2', " cp \"$notes_file\" \"${state_dir}/release-${tag}.body\"", + " printf \"%s\" \"$target\" >\"${state_dir}/release-${tag}.target\"", " printf \"https://example.invalid/releases/tag/%s\\n\" \"$tag\"", " exit 0", "fi", @@ -188,6 +143,91 @@ def test_script_smoke_release_publish_from_changelog(tmp_path: Path): ), ) + +@pytest.mark.script_smoke +def test_script_smoke_release_and_ci_specs_match_retained_entrypoints() -> None: + repo = repo_root() + + expected_specs = [ + "tests/script_specs/skills/automation/gh-fix-ci/scripts/gh-fix-ci.sh.json", + "tests/script_specs/skills/automation/gh-fix-ci/scripts/inspect_ci_checks.py.json", + "tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json", + "tests/script_specs/skills/automation/release-workflow/scripts/release-resolve.sh.json", + ] + spec_roots = [ + repo / "tests" / "script_specs" / "skills" / "automation" / "gh-fix-ci" / "scripts", + repo / "tests" / "script_specs" / "skills" / "automation" / "release-workflow" / "scripts", + ] + discovered_specs = sorted( + str(path.relative_to(repo)) + for root in spec_roots + for path in root.glob("*.json") + ) + assert discovered_specs == expected_specs + + expected_scripts = [ + "skills/automation/gh-fix-ci/scripts/gh-fix-ci.sh", + "skills/automation/gh-fix-ci/scripts/inspect_ci_checks.py", + "skills/automation/release-workflow/scripts/release-publish-from-changelog.sh", + "skills/automation/release-workflow/scripts/release-resolve.sh", + ] + for script_path in expected_scripts: + assert (repo / script_path).is_file(), script_path + + expected_case_names = { + "tests/script_specs/skills/automation/gh-fix-ci/scripts/gh-fix-ci.sh.json": {"help-surface"}, + "tests/script_specs/skills/automation/gh-fix-ci/scripts/inspect_ci_checks.py.json": {"help"}, + "tests/script_specs/skills/automation/release-workflow/scripts/release-publish-from-changelog.sh.json": { + "help-surface" + }, + "tests/script_specs/skills/automation/release-workflow/scripts/release-resolve.sh.json": {"json-defaults"}, + } + for spec_path, expected_names in expected_case_names.items(): + payload = json.loads((repo / spec_path).read_text("utf-8")) + smoke_cases = payload.get("smoke", []) + names = {case.get("name") for case in smoke_cases if isinstance(case, dict)} + assert names == expected_names, spec_path + + +@pytest.mark.script_smoke +def test_script_smoke_release_publish_from_changelog(tmp_path: Path): + work_dir, _ = init_release_fixture_repo(tmp_path) + + version = "v1.2.3" + changelog = work_dir / "CHANGELOG.md" + changelog.write_text( + "\n".join( + [ + "# Changelog", + "", + "All notable changes to this project will be documented in this file.", + "", + "## v1.2.3 - 2026-01-01", + "", + "### Added", + "", + "- Added a thing", + "", + "### Changed", + "", + "- Changed a thing", + "", + "### Fixed", + "", + "- Fixed a thing", + "", + ] + ) + + "\n", + "utf-8", + ) + git(["add", "CHANGELOG.md"], cwd=work_dir) + git(["commit", "-m", "chore(release): prepare v1.2.3 changelog"], cwd=work_dir) + git(["push", "origin", "main"], cwd=work_dir) + + fixture_bin = tmp_path / "bin" + write_release_gh_fixture(fixture_bin / "gh") + repo = repo_root() stub_bin = repo / "tests" / "stubs" / "bin" system_path = os.environ.get("PATH", "") @@ -229,11 +269,160 @@ def test_script_smoke_release_publish_from_changelog(tmp_path: Path): out_text = out_path.read_text("utf-8") assert f"## {version} - " in out_text + head_sha = git(["rev-parse", "HEAD"], cwd=work_dir).stdout.strip() + target_file = work_dir / ".gh-state" / f"release-{version}.target" + assert target_file.read_text("utf-8") == head_sha + + +@pytest.mark.script_smoke +def test_script_smoke_release_publish_requires_synced_upstream_by_default(tmp_path: Path): + work_dir, _ = init_release_fixture_repo(tmp_path) + + version = "v1.2.4" + changelog = work_dir / "CHANGELOG.md" + changelog.write_text( + "\n".join( + [ + "# Changelog", + "", + "All notable changes to this project will be documented in this file.", + "", + "## v1.2.4 - 2026-01-02", + "", + "### Added", + "", + "- Added a thing", + "", + ] + ) + + "\n", + "utf-8", + ) + git(["add", "CHANGELOG.md"], cwd=work_dir) + git(["commit", "-m", "chore(release): prepare v1.2.4 changelog"], cwd=work_dir) + + fixture_bin = tmp_path / "bin" + write_release_gh_fixture(fixture_bin / "gh") + + repo = repo_root() + stub_bin = repo / "tests" / "stubs" / "bin" + system_path = os.environ.get("PATH", "") + path = os.pathsep.join([str(fixture_bin), str(stub_bin), system_path]) + + script = "skills/automation/release-workflow/scripts/release-publish-from-changelog.sh" + spec = { + "args": [ + "--repo", + ".", + "--version", + version, + "--changelog", + "CHANGELOG.md", + "--notes-output", + "release-notes.md", + ], + "env": {"PATH": path, "GH_STUB_STATE_DIR": ".gh-state"}, + "timeout_sec": 10, + "expect": { + "exit_codes": [2], + "stderr_regex": r"ahead of .*push first or rerun with --push-current-branch", + }, + } + + result = run_smoke_script(script, "release-publish-ahead-of-upstream", spec, repo, cwd=work_dir) + SCRIPT_SMOKE_RUN_RESULTS.append(result) + + assert result.status == "pass", ( + f"script smoke (fixture) failed: {script} (exit={result.exit_code})\\n" + f"argv: {' '.join(result.argv)}\\n" + f"stdout: {result.stdout_path}\\n" + f"stderr: {result.stderr_path}\\n" + f"note: {result.note or 'None'}" + ) + + calls_path = work_dir / ".gh-state" / "gh.calls.txt" + assert not calls_path.exists() + assert not (work_dir / ".gh-state" / f"release-{version}.body").exists() + + +@pytest.mark.script_smoke +def test_script_smoke_release_publish_pushes_current_branch_when_requested(tmp_path: Path): + work_dir, origin = init_release_fixture_repo(tmp_path) + + version = "v1.2.5" + changelog = work_dir / "CHANGELOG.md" + changelog.write_text( + "\n".join( + [ + "# Changelog", + "", + "All notable changes to this project will be documented in this file.", + "", + "## v1.2.5 - 2026-01-03", + "", + "### Changed", + "", + "- Changed a thing", + "", + ] + ) + + "\n", + "utf-8", + ) + git(["add", "CHANGELOG.md"], cwd=work_dir) + git(["commit", "-m", "chore(release): prepare v1.2.5 changelog"], cwd=work_dir) + + fixture_bin = tmp_path / "bin" + write_release_gh_fixture(fixture_bin / "gh") + + repo = repo_root() + stub_bin = repo / "tests" / "stubs" / "bin" + system_path = os.environ.get("PATH", "") + path = os.pathsep.join([str(fixture_bin), str(stub_bin), system_path]) + + script = "skills/automation/release-workflow/scripts/release-publish-from-changelog.sh" + spec = { + "args": [ + "--repo", + ".", + "--version", + version, + "--changelog", + "CHANGELOG.md", + "--notes-output", + "release-notes.md", + "--push-current-branch", + ], + "env": {"PATH": path, "GH_STUB_STATE_DIR": ".gh-state"}, + "timeout_sec": 10, + "expect": { + "exit_codes": [0], + "stdout_regex": r"^https://example\.invalid/releases/tag/v1\.2\.5$", + }, + } + + result = run_smoke_script(script, "release-publish-with-push-current-branch", spec, repo, cwd=work_dir) + SCRIPT_SMOKE_RUN_RESULTS.append(result) + + assert result.status == "pass", ( + f"script smoke (fixture) failed: {script} (exit={result.exit_code})\\n" + f"argv: {' '.join(result.argv)}\\n" + f"stdout: {result.stdout_path}\\n" + f"stderr: {result.stderr_path}\\n" + f"note: {result.note or 'None'}" + ) + + head_sha = git(["rev-parse", "HEAD"], cwd=work_dir).stdout.strip() + remote_sha = git(["rev-parse", "refs/heads/main"], cwd=origin).stdout.strip() + assert remote_sha == head_sha + + target_file = work_dir / ".gh-state" / f"release-{version}.target" + assert target_file.read_text("utf-8") == head_sha + @pytest.mark.script_smoke def test_script_smoke_release_publish_keeps_existing_output_on_failure(tmp_path: Path): - work_dir = tmp_path / "release" - work_dir.mkdir(parents=True, exist_ok=True) + work_dir, _ = init_release_fixture_repo(tmp_path) changelog = work_dir / "CHANGELOG.md" changelog.write_text( @@ -254,8 +443,11 @@ def test_script_smoke_release_publish_keeps_existing_output_on_failure(tmp_path: + "\n", "utf-8", ) + git(["add", "CHANGELOG.md"], cwd=work_dir) + git(["commit", "-m", "chore(release): prepare v1.2.2 changelog"], cwd=work_dir) + git(["push", "origin", "main"], cwd=work_dir) - out_path = work_dir / "release-notes.md" + out_path = tmp_path / "release-notes.md" out_path.write_text("sentinel-old-content\n", "utf-8") repo = repo_root() @@ -269,7 +461,7 @@ def test_script_smoke_release_publish_keeps_existing_output_on_failure(tmp_path: "--changelog", "CHANGELOG.md", "--notes-output", - "release-notes.md", + str(out_path), ], "timeout_sec": 10, "expect": {"exit_codes": [2], "stderr_regex": r"version section not found"},