diff --git a/CHANGELOG.md b/CHANGELOG.md index f5be45f1..34632589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) + - Add `prepare-changelog unprepare` to rename the top `## [semver] - …` heading to `## Unreleased` + - `init-workspace.sh --smoke-test` copies `.devcontainer/CHANGELOG.md` into workspace `CHANGELOG.md` and runs unprepare; remove duplicate remap from smoke-test dispatch workflow - **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` diff --git a/assets/init-workspace.sh b/assets/init-workspace.sh index a4d53092..f854566f 100755 --- a/assets/init-workspace.sh +++ b/assets/init-workspace.sh @@ -228,6 +228,18 @@ if [[ "$SMOKE_TEST" == "true" ]]; then else echo "Warning: Smoke-test directory not found at $SMOKE_TEST_DIR" >&2 fi + + # Workspace scaffold CHANGELOG is empty; copy devcontainer changelog and + # rename top ## [version] - … to ## Unreleased for downstream prepare-release. + if [[ -f "$WORKSPACE_DIR/.devcontainer/CHANGELOG.md" ]]; then + echo "Syncing workspace CHANGELOG from .devcontainer/CHANGELOG.md (smoke-test)..." + cp "$WORKSPACE_DIR/.devcontainer/CHANGELOG.md" "$WORKSPACE_DIR/CHANGELOG.md" + if ! command -v prepare-changelog >/dev/null 2>&1; then + echo "ERROR: prepare-changelog not found (required for smoke-test CHANGELOG sync)" >&2 + exit 1 + fi + prepare-changelog unprepare "$WORKSPACE_DIR/CHANGELOG.md" + fi else # Build exclude list for preserved files that already exist EXCLUDE_ARGS=() diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 90c94125..fe96f3ac 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -240,16 +240,6 @@ jobs: echo "ERROR: CHANGELOG.md is not readable after ownership repair" exit 1 fi - FIRST_CHANGELOG_SECTION="$(awk '/^## / { print; exit }' CHANGELOG.md)" - if [ "${FIRST_CHANGELOG_SECTION}" = "## Unreleased" ]; then - : - elif printf '%s\n' "${FIRST_CHANGELOG_SECTION}" | grep -Eq '^## \[[^]]+\] - '; then - echo "Remapping top ## [version] - to ## Unreleased for prepare-release" - sed -i '0,/^## \[[^]]*\] - /s/^## \[[^]]*\] - .*/## Unreleased/' CHANGELOG.md - else - echo "ERROR: CHANGELOG.md first section is neither ## Unreleased nor ## [version] - : ${FIRST_CHANGELOG_SECTION:-}" - exit 1 - fi - name: Prepare deploy branch and metadata id: prepare_branch diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index f5be45f1..34632589 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -73,6 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) + - Add `prepare-changelog unprepare` to rename the top `## [semver] - …` heading to `## Unreleased` + - `init-workspace.sh --smoke-test` copies `.devcontainer/CHANGELOG.md` into workspace `CHANGELOG.md` and runs unprepare; remove duplicate remap from smoke-test dispatch workflow - **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` diff --git a/packages/vig-utils/README.md b/packages/vig-utils/README.md index 32ef6437..52928e1d 100644 --- a/packages/vig-utils/README.md +++ b/packages/vig-utils/README.md @@ -25,7 +25,7 @@ uv run check-action-pins --help |---|---|---| | `validate-commit-msg` | Python | Enforce commit message standard | | `check-action-pins` | Python | Ensure GitHub Actions are SHA pinned | -| `prepare-changelog` | Python | Validate/prepare/finalize/reset changelog | +| `prepare-changelog` | Python | Validate/prepare/finalize/reset/unprepare changelog | | `gh-issues` | Python | Rich issue/PR dashboard via `gh` | | `prepare-commit-msg-strip-trailers` | Python | Remove blocked trailers from commit messages | | `check-agent-identity` | Python | Block commits from agent fingerprints in author identity | @@ -87,6 +87,7 @@ prepare-changelog validate [FILE] prepare-changelog prepare [FILE] prepare-changelog finalize [FILE] prepare-changelog reset [FILE] +prepare-changelog unprepare [FILE] ``` Examples: @@ -96,6 +97,7 @@ prepare-changelog validate prepare-changelog prepare 0.3.0 prepare-changelog finalize 0.3.0 2026-03-04 prepare-changelog reset +prepare-changelog unprepare ``` ### `gh-issues` diff --git a/packages/vig-utils/src/vig_utils/prepare_changelog.py b/packages/vig-utils/src/vig_utils/prepare_changelog.py index 403c6ee5..ae4dd8b3 100644 --- a/packages/vig-utils/src/vig_utils/prepare_changelog.py +++ b/packages/vig-utils/src/vig_utils/prepare_changelog.py @@ -175,6 +175,52 @@ def reset_unreleased(filepath="CHANGELOG.md"): raise ValueError("Could not find appropriate location for Unreleased section") +def unprepare_changelog(filepath="CHANGELOG.md"): + """ + Rename the first top-level version section to ## Unreleased (inverse of prepare). + + Used when the workspace CHANGELOG was replaced by a scaffold but the canonical + entries live under ``## [X.Y.Z] - …`` (e.g. copied from ``.devcontainer/CHANGELOG.md``). + + - If the first ``## `` heading is already ``## Unreleased``, no-op. + - If it matches ``## [MAJOR.MINOR.PATCH] - …`` (semver + suffix), replace with + ``## Unreleased``. + - Otherwise raises ValueError. + + Args: + filepath: Path to CHANGELOG.md + + Returns: + True if the file was modified, False if already ``## Unreleased``. + """ + path = Path(filepath) + if not path.exists(): + raise FileNotFoundError(f"CHANGELOG not found: {filepath}") + + content = path.read_text() + match = re.search(r"^## .+$", content, re.MULTILINE) + if not match: + raise ValueError("No top-level ## heading found in CHANGELOG") + + line = match.group(0).rstrip("\r\n") + if line == "## Unreleased": + return False + + # Match ## [X.Y.Z] - TBD or ## [X.Y.Z] - YYYY-MM-DD (same semver rule as prepare) + version_heading = re.compile( + r"^## \[(\d+\.\d+\.\d+)\] - .+$", + ) + if not version_heading.match(line): + raise ValueError( + f"Unexpected first CHANGELOG section heading: {line!r} " + "(expected ## Unreleased or ## [semver] - …)" + ) + + new_content = content[: match.start()] + "## Unreleased" + content[match.end() :] + path.write_text(new_content) + return True + + def prepare_changelog(version, filepath="CHANGELOG.md"): """ Prepare CHANGELOG for release. @@ -262,6 +308,14 @@ def cmd_reset(args): print("✓ Created fresh empty section for next release") +def cmd_unprepare(args): + """Handle unprepare command.""" + if unprepare_changelog(args.file): + print(f"✓ Renamed top version section to ## Unreleased in {args.file}") + else: + print(f"✓ Top section already ## Unreleased in {args.file} (no changes)") + + def finalize_release_date(version, release_date, filepath="CHANGELOG.md"): """ Replace TBD date with actual release date for a version. @@ -332,6 +386,9 @@ def main(): # Reset Unreleased section after release merge %(prog)s reset + + # Rename top ## [version] - … to ## Unreleased (smoke-test deploy sync) + %(prog)s unprepare """, ) @@ -385,6 +442,19 @@ def main(): ) reset_parser.set_defaults(func=cmd_reset) + # unprepare command + unprepare_parser = subparsers.add_parser( + "unprepare", + help="Rename first ## [semver] - … heading to ## Unreleased", + ) + unprepare_parser.add_argument( + "file", + nargs="?", + default="CHANGELOG.md", + help="Path to CHANGELOG file (default: CHANGELOG.md)", + ) + unprepare_parser.set_defaults(func=cmd_unprepare) + # finalize command finalize_parser = subparsers.add_parser( "finalize", diff --git a/packages/vig-utils/tests/test_prepare_changelog.py b/packages/vig-utils/tests/test_prepare_changelog.py index 19b5ec85..3b2fda57 100644 --- a/packages/vig-utils/tests/test_prepare_changelog.py +++ b/packages/vig-utils/tests/test_prepare_changelog.py @@ -10,6 +10,7 @@ Tests are organized by function under test, from low-level helpers up to the CLI layer. """ +import re import shutil import subprocess from unittest.mock import patch @@ -20,6 +21,7 @@ cmd_finalize, cmd_prepare, cmd_reset, + cmd_unprepare, cmd_validate, create_new_changelog, extract_unreleased_content, @@ -27,6 +29,7 @@ main, prepare_changelog, reset_unreleased, + unprepare_changelog, validate_changelog, ) @@ -872,6 +875,115 @@ def test_raises_when_no_version_heading(self, tmp_path): reset_unreleased(str(f)) +# ═════════════════════════════════════════════════════════════════════════════ +# unprepare_changelog +# ═════════════════════════════════════════════════════════════════════════════ + +TOP_VERSION_TBD_THEN_OLDER = """\ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - TBD + +### Added + +- **Feature** ([#1](https://example.com/1)) + +## [0.9.0] - 2026-01-01 + +### Added + +- Prior +""" + + +class TestUnprepareChangelog: + """Unit tests for unprepare_changelog().""" + + def test_renames_tbd_header(self, tmp_path): + """Top ## [semver] - TBD becomes ## Unreleased; body preserved.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + assert unprepare_changelog(str(f)) is True + text = f.read_text() + assert text.startswith("# Changelog") + first_h2 = re.search(r"^## .+$", text, re.MULTILINE) + assert first_h2 is not None + assert first_h2.group(0) == "## Unreleased" + assert "## [1.0.0] - TBD" not in text + assert "**Feature**" in text + assert "## [0.9.0] - 2026-01-01" in text + + def test_renames_dated_header(self, tmp_path): + """Top ## [semver] - YYYY-MM-DD becomes ## Unreleased.""" + body = """\ +# Changelog + +## [2.0.0] - 2026-03-23 + +### Fixed + +- Bug + +""" + f = tmp_path / "CHANGELOG.md" + f.write_text(body) + assert unprepare_changelog(str(f)) is True + assert f.read_text().split("\n")[2] == "## Unreleased" + assert "- Bug" in f.read_text() + + def test_noop_when_already_unreleased(self, tmp_path): + """Returns False and leaves file unchanged.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(BASIC_CHANGELOG) + before = f.read_text() + assert unprepare_changelog(str(f)) is False + assert f.read_text() == before + + def test_raises_no_heading(self, tmp_path): + """No ## line raises ValueError.""" + f = tmp_path / "CHANGELOG.md" + f.write_text("# Title only\n\nNo section.\n") + with pytest.raises(ValueError, match="No top-level"): + unprepare_changelog(str(f)) + + def test_raises_unexpected_heading(self, tmp_path): + """Non-version first ## heading raises.""" + f = tmp_path / "CHANGELOG.md" + f.write_text("# C\n\n## Random\n\n- x\n") + with pytest.raises(ValueError, match="Unexpected first"): + unprepare_changelog(str(f)) + + def test_raises_missing_file(self, tmp_path): + with pytest.raises(FileNotFoundError, match="CHANGELOG not found"): + unprepare_changelog(str(tmp_path / "missing.md")) + + +class TestCmdUnprepare: + """Tests for cmd_unprepare handler.""" + + def _make_args(self, filepath): + from argparse import Namespace + + return Namespace(file=filepath) + + def test_output_when_modified(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + cmd_unprepare(self._make_args(str(f))) + out = capsys.readouterr().out + assert "Renamed" in out + assert "## Unreleased" in f.read_text() + + def test_output_when_noop(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(BASIC_CHANGELOG) + cmd_unprepare(self._make_args(str(f))) + out = capsys.readouterr().out + assert "no changes" in out.lower() or "already" in out.lower() + + # ═════════════════════════════════════════════════════════════════════════════ # finalize_release_date # ═════════════════════════════════════════════════════════════════════════════ @@ -1210,6 +1322,16 @@ def test_finalize_via_main(self, tmp_path): main() assert "## [1.0.0] - 2026-02-11" in f.read_text() + def test_unprepare_via_main(self, tmp_path): + """main() with 'unprepare' should rename top version heading.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + with patch("sys.argv", ["prog", "unprepare", str(f)]): + main() + first = re.search(r"^## .+$", f.read_text(), re.MULTILINE) + assert first is not None + assert first.group(0) == "## Unreleased" + def test_main_catches_exceptions(self, tmp_path): """main() should convert exceptions to stderr + exit(1).""" with ( @@ -1324,3 +1446,24 @@ def test_finalize_version_not_found_e2e(self, tmp_path): f.write_text(CHANGELOG_WITH_TBD) result = self._run("finalize", "9.9.9", "2026-02-11", str(f)) assert result.returncode != 0 + + # ── unprepare ───────────────────────────────────────────────────────── + + def test_unprepare_e2e(self, tmp_path): + """unprepare via subprocess renames top version heading.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + result = self._run("unprepare", str(f)) + assert result.returncode == 0 + first = re.search(r"^## .+$", f.read_text(), re.MULTILINE) + assert first is not None + assert first.group(0) == "## Unreleased" + + def test_unprepare_noop_e2e(self, tmp_path): + """unprepare leaves Unreleased changelog unchanged.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(BASIC_CHANGELOG) + before = f.read_text() + result = self._run("unprepare", str(f)) + assert result.returncode == 0 + assert f.read_text() == before diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 731fe1d8..9d5d0410 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -75,8 +75,8 @@ setup() { assert_success } -@test "smoke-test dispatch normalizes workspace changelog for prepare-release freeze" { - run bash -lc 'grep -Fq -- "expected CHANGELOG.md after install (workspace scaffold)" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "FIRST_CHANGELOG_SECTION" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "## Unreleased" assets/smoke-test/.github/workflows/repository-dispatch.yml' +@test "smoke-test dispatch validates workspace changelog exists after install" { + run bash -lc 'grep -Fq -- "expected CHANGELOG.md after install (workspace scaffold)" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "CHANGELOG.md is not readable after ownership repair" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } diff --git a/tests/test_integration.py b/tests/test_integration.py index f09d0c53..b9fa31fd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -908,13 +908,15 @@ def test_smoke_workspace_changelog_available_in_devcontainer_and_root( root_content = root_changelog.read_text(encoding="utf-8") devcontainer_content = devcontainer_changelog.read_text(encoding="utf-8") - # Root changelog is workspace-owned; .devcontainer changelog is the canonical - # upstream release history synced from the template manifest. - assert "## Unreleased" in root_content, ( - "Root changelog should expose workspace Unreleased section" - ) - assert "## [" not in root_content, ( - "Root changelog should remain a workspace stub without versioned releases" + # Root changelog is a copy of .devcontainer/CHANGELOG.md with the top semver + # heading renamed via prepare-changelog unprepare; older release sections stay. + first_h2 = re.search(r"^## .+$", root_content, re.MULTILINE) + assert first_h2 is not None, "Root changelog should have a top-level ## heading" + assert first_h2.group(0).rstrip("\r\n") == "## Unreleased", ( + "Root changelog top section should be ## Unreleased after smoke-test unprepare" + ) + assert re.search(r"^## \[\d+\.\d+\.\d+\]", root_content, re.MULTILINE), ( + "Root changelog should retain semver release sections below Unreleased" ) assert re.search( r"^## \[\d+\.\d+\.\d+\]", devcontainer_content, re.MULTILINE