Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions assets/init-workspace.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=()
Expand Down
10 changes: 0 additions & 10 deletions assets/smoke-test/.github/workflows/repository-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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] - <date> to ## Unreleased for prepare-release"
sed -i '0,/^## \[[^]]*\] - /s/^## \[[^]]*\] - .*/## Unreleased/' CHANGELOG.md
else
echo "ERROR: CHANGELOG.md first section is neither ## Unreleased nor ## [version] - <date>: ${FIRST_CHANGELOG_SECTION:-<empty>}"
exit 1
fi

- name: Prepare deploy branch and metadata
id: prepare_branch
Expand Down
3 changes: 3 additions & 0 deletions assets/workspace/.devcontainer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion packages/vig-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -87,6 +87,7 @@ prepare-changelog validate [FILE]
prepare-changelog prepare <VERSION> [FILE]
prepare-changelog finalize <VERSION> <YYYY-MM-DD> [FILE]
prepare-changelog reset [FILE]
prepare-changelog unprepare [FILE]
```

Examples:
Expand All @@ -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`
Expand Down
70 changes: 70 additions & 0 deletions packages/vig-utils/src/vig_utils/prepare_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
""",
)

Expand Down Expand Up @@ -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",
Expand Down
143 changes: 143 additions & 0 deletions packages/vig-utils/tests/test_prepare_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,13 +21,15 @@
cmd_finalize,
cmd_prepare,
cmd_reset,
cmd_unprepare,
cmd_validate,
create_new_changelog,
extract_unreleased_content,
finalize_release_date,
main,
prepare_changelog,
reset_unreleased,
unprepare_changelog,
validate_changelog,
)

Expand Down Expand Up @@ -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
# ═════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/bats/just.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
16 changes: 9 additions & 7 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading