diff --git a/.changes/archive/.gitkeep b/.changes/archive/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/.changes/archive/.gitkeep @@ -0,0 +1 @@ + diff --git a/.changes/unreleased/.gitkeep b/.changes/unreleased/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/.changes/unreleased/.gitkeep @@ -0,0 +1 @@ + diff --git a/.github/workflows/changelog-fragment-draft.yml b/.github/workflows/changelog-fragment-draft.yml new file mode 100644 index 0000000000..210eea1002 --- /dev/null +++ b/.github/workflows/changelog-fragment-draft.yml @@ -0,0 +1,222 @@ +name: Draft changelog fragment + +on: + pull_request_target: + branches: + - main + - next + types: [opened, reopened, synchronize, labeled, unlabeled] + +permissions: + models: read + pull-requests: write + +concurrency: + group: "${{ github.workflow }}-${{ github.event.pull_request.number }}" + cancel-in-progress: true + +jobs: + draft: + runs-on: ubuntu-latest + steps: + - name: Collect PR context + id: context + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + + const hasNoChangelog = (pr.labels || []).some(label => label.name === 'no changelog'); + const hasFragment = files.some(file => /^\.changes\/unreleased\/.+\.md$/.test(file.filename)); + const shouldRun = !hasNoChangelog && !hasFragment; + + const changedFiles = files.map(file => file.filename).join('\n') || '(no changed files reported)'; + + const diffSections = []; + let totalChars = 0; + for (const file of files) { + const patch = file.patch || '[diff omitted by GitHub]'; + const truncatedPatch = patch.length > 4000 ? `${patch.slice(0, 4000)}\n...[truncated]` : patch; + const section = `### ${file.filename}\n${truncatedPatch}`; + if (totalChars + section.length > 20000) { + diffSections.push('...[additional diff omitted]'); + break; + } + diffSections.push(section); + totalChars += section.length; + } + + const tempDir = process.env.RUNNER_TEMP; + const inputsDir = path.join(tempDir, 'changelog-fragment-inputs'); + fs.mkdirSync(inputsDir, { recursive: true }); + + const bodyPath = path.join(inputsDir, 'body.txt'); + const titlePath = path.join(inputsDir, 'title.txt'); + const changedFilesPath = path.join(inputsDir, 'changed_files.txt'); + const diffPath = path.join(inputsDir, 'diff.txt'); + const promptPath = path.join(inputsDir, 'prompt.prompt.yml'); + + fs.writeFileSync(bodyPath, pr.body || ''); + fs.writeFileSync(titlePath, pr.title || ''); + fs.writeFileSync(changedFilesPath, changedFiles); + fs.writeFileSync(diffPath, diffSections.join('\n\n')); + fs.writeFileSync(promptPath, `messages: + - role: system + content: |- + You draft changelog fragments for a Rust workspace. + Return JSON only. + Pick the smallest accurate kind from: breaking, change, enhancement, fix. + Write a single concise, user-facing summary sentence. + Do not mention tests, reviews, CI, or implementation details unless they matter to users. + Only set crate when a single obvious crate is central to the change; otherwise use an empty string. + - role: user + content: |- + Draft a changelog fragment from this pull request. + + Pull request URL: + {{pr_url}} + + Pull request title: + {{title}} + + Pull request body: + {{body}} + + Changed files: + {{changed_files}} + + Unified diff: + {{diff}} + model: openai/gpt-4.1-mini + responseFormat: json_schema + jsonSchema: |- + { + "name": "changelog_fragment", + "strict": true, + "schema": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["breaking", "change", "enhancement", "fix"] + }, + "crate": { + "type": "string" + }, + "summary": { + "type": "string" + } + }, + "required": ["kind", "crate", "summary"], + "additionalProperties": false + } + } + modelParameters: + temperature: 0.2 + maxCompletionTokens: 250 + `); + + core.setOutput('should_run', shouldRun ? 'true' : 'false'); + core.setOutput('pr_number', String(pr.number)); + core.setOutput('pr_url', pr.html_url); + core.setOutput('body_path', bodyPath); + core.setOutput('title_path', titlePath); + core.setOutput('changed_files_path', changedFilesPath); + core.setOutput('diff_path', diffPath); + core.setOutput('prompt_path', promptPath); + + - name: Run AI inference + if: ${{ steps.context.outputs.should_run == 'true' }} + id: inference + uses: actions/ai-inference@v1 + with: + prompt-file: ${{ steps.context.outputs.prompt_path }} + input: | + pr_url: ${{ steps.context.outputs.pr_url }} + file_input: | + title: ${{ steps.context.outputs.title_path }} + body: ${{ steps.context.outputs.body_path }} + changed_files: ${{ steps.context.outputs.changed_files_path }} + diff: ${{ steps.context.outputs.diff_path }} + + - name: Comment fragment instructions + if: ${{ steps.context.outputs.should_run == 'true' }} + env: + PR_NUMBER: ${{ steps.context.outputs.pr_number }} + PR_URL: ${{ steps.context.outputs.pr_url }} + RESPONSE_FILE: ${{ steps.inference.outputs.response-file }} + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const prNumber = process.env.PR_NUMBER; + const prUrl = process.env.PR_URL; + const response = JSON.parse(fs.readFileSync(process.env.RESPONSE_FILE, 'utf8')); + const marker = ''; + const kind = response.kind; + const crate = response.crate || ''; + const summary = response.summary; + const repoFragmentPath = `.changes/unreleased/${prNumber}.${kind}.md`; + + const frontMatter = [ + '---', + `kind: ${kind}`, + `pr: ${prUrl}`, + ...(crate ? [`crate: ${crate}`] : []), + '---', + '', + summary, + ]; + const fragment = frontMatter.join('\n'); + const body = [ + marker, + 'This pull request does not include a changelog fragment and is not labeled `no changelog`.', + '', + `Please add a file at \`${repoFragmentPath}\` with content like:`, + '', + '```md', + fragment, + '```', + '', + 'If no changelog entry is needed, ask a maintainer to apply the `no changelog` label.', + ].join('\n'); + + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } diff --git a/.github/workflows/changelog-release.yml b/.github/workflows/changelog-release.yml new file mode 100644 index 0000000000..88ce842bca --- /dev/null +++ b/.github/workflows/changelog-release.yml @@ -0,0 +1,46 @@ +name: Prepare changelog release + +on: + workflow_dispatch: + inputs: + version: + description: Release version, e.g. 0.23.0 + required: true + type: string + date: + description: Release date in YYYY-MM-DD format. Defaults to today if empty. + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Prepare changelog release + run: | + if [ -n "${{ inputs.date }}" ]; then + ./scripts/prepare-changelog-release.py --version "${{ inputs.version }}" --date "${{ inputs.date }}" + else + ./scripts/prepare-changelog-release.py --version "${{ inputs.version }}" + fi + + - name: Commit prepared changelog + run: | + if git diff --quiet -- CHANGELOG.md .changes; then + echo "No changelog changes were produced." + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md .changes + git commit -m "chore(changelog): prepare ${{ inputs.version }}" + git push diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index f159d08757..d15dda753e 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,5 +1,4 @@ # Runs changelog related jobs. -# CI job heavily inspired by: https://github.com/tarides/changelog-check-action name: changelog @@ -25,5 +24,5 @@ jobs: env: BASE_REF: ${{ github.event.pull_request.base.ref }} NO_CHANGELOG_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') }} - run: ./scripts/check-changelog.sh "${{ inputs.changelog }}" + run: ./scripts/check-changelog.sh shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba75d8f0cb..3e80b915eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,11 @@ For example, a new change to the AIR crate might have the following message: `fe ### Versioning We use [semver](https://semver.org/) naming convention. +### Changelog fragments +- Normal PRs should add a changelog fragment under `.changes/unreleased/` instead of editing `CHANGELOG.md`. +- If a PR does not need a changelog entry, ask a maintainer to apply the `no changelog` label. +- Fragment format details live in `.changes/README.md`. +   ## Pre-PR checklist @@ -110,6 +115,7 @@ We use [semver](https://semver.org/) naming convention. 5. Documentation/comments updated for all changes according to our documentation convention. 6. Clippy and Rustfmt linting passed. 7. New branch rebased from `next`. +8. A changelog fragment was added in `.changes/unreleased/`, or the PR has the `no changelog` label.   diff --git a/scripts/check-changelog.sh b/scripts/check-changelog.sh index 7d7529daa2..108509627d 100755 --- a/scripts/check-changelog.sh +++ b/scripts/check-changelog.sh @@ -1,21 +1,35 @@ #!/bin/bash -set -uo pipefail +set -euo pipefail -CHANGELOG_FILE="${1:-CHANGELOG.md}" +FRAGMENT_GLOB=':(glob).changes/unreleased/*.md' -if [ "${NO_CHANGELOG_LABEL}" = "true" ]; then - # 'no changelog' set, so finish successfully +if [[ "${NO_CHANGELOG_LABEL:-false}" == "true" ]]; then echo "\"no changelog\" label has been set" exit 0 -else - # a changelog check is required - # fail if the diff is empty - if git diff --exit-code "origin/${BASE_REF}" -- "${CHANGELOG_FILE}"; then - >&2 echo "Changes should come with an entry in the \"CHANGELOG.md\" file. This behavior -can be overridden by using the \"no changelog\" label, which is used for changes -that are trivial / explicitly stated not to require a changelog entry." - exit 1 - fi +fi + +if [[ -z "${BASE_REF:-}" ]]; then + echo "BASE_REF must be set" >&2 + exit 1 +fi - echo "The \"CHANGELOG.md\" file has been updated." +fragments=() +while IFS= read -r fragment; do + [[ -n "$fragment" ]] && fragments+=("$fragment") +done < <(git diff --name-only --diff-filter=AMR "origin/${BASE_REF}...HEAD" -- "$FRAGMENT_GLOB") + +if [[ "${#fragments[@]}" -eq 0 ]]; then + cat >&2 <<'EOF' +Changes should come with a changelog fragment in ".changes/unreleased/". +This behavior can be overridden by using the "no changelog" label for changes +that are trivial or explicitly stated not to require a changelog entry. +EOF + exit 1 fi + +validate_args=() +for fragment in "${fragments[@]}"; do + validate_args+=(--validate-fragment "$fragment") +done + +./scripts/prepare-changelog-release.py "${validate_args[@]}" diff --git a/scripts/prepare-changelog-release.py b/scripts/prepare-changelog-release.py new file mode 100755 index 0000000000..7c0e2b31b9 --- /dev/null +++ b/scripts/prepare-changelog-release.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import pathlib +import re +import shutil +import sys + + +ROOT = pathlib.Path(__file__).resolve().parent.parent +CHANGELOG_PATH = ROOT / "CHANGELOG.md" +UNRELEASED_DIR = ROOT / ".changes" / "unreleased" +ARCHIVE_DIR = ROOT / ".changes" / "archive" +SECTION_ORDER = ["enhancement", "change", "fix"] +ALLOWED_KINDS = {"breaking", "change", "enhancement", "fix"} +SECTION_HEADERS = { + "enhancement": "Enhancements", + "change": "Changes", + "fix": "Fixes", +} + + +class FragmentError(RuntimeError): + pass + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Batch changelog fragments into CHANGELOG.md and archive them." + ) + parser.add_argument("--version", help="Release version, e.g. 0.23.0") + parser.add_argument( + "--date", + default=dt.date.today().isoformat(), + help="Release date in YYYY-MM-DD format", + ) + parser.add_argument( + "--validate-fragment", + action="append", + default=[], + metavar="PATH", + help="Validate one or more fragment files and exit without updating the changelog.", + ) + args = parser.parse_args() + if args.validate_fragment and args.version: + parser.error("--validate-fragment cannot be used together with --version") + if not args.validate_fragment and not args.version: + parser.error("either --version or --validate-fragment is required") + return args + + +def normalize_front_matter_value(raw: str) -> str: + value = raw.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + if value in {"null", "~"}: + return "" + return value + + +def parse_front_matter(path: pathlib.Path, lines: list[str]) -> tuple[dict[str, str], int]: + if len(lines) < 4 or lines[0] != "---": + raise FragmentError(f"{path} must start with YAML front matter delimited by '---'") + + try: + closing = lines.index("---", 1) + except ValueError as exc: + raise FragmentError(f"{path} is missing the closing front matter delimiter") from exc + + front_matter: dict[str, str] = {} + for line in lines[1:closing]: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if ":" not in stripped: + raise FragmentError(f"{path} has invalid front matter line: {line}") + key, value = stripped.split(":", 1) + key = key.strip() + if not key: + raise FragmentError(f"{path} has invalid front matter line: {line}") + if key in front_matter: + raise FragmentError(f"{path} defines front matter key '{key}' more than once") + front_matter[key] = normalize_front_matter_value(value) + + return front_matter, closing + + +def parse_fragment(path: pathlib.Path) -> dict[str, str]: + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + front_matter, closing = parse_front_matter(path, lines) + + kind = front_matter.get("kind", "") + if kind not in ALLOWED_KINDS: + raise FragmentError(f"{path} has invalid kind '{kind}'") + + pr_url = front_matter.get("pr", "") + if not re.match(r"^https://github\.com/[^/]+/[^/]+/pull/\d+$", pr_url): + raise FragmentError(f"{path} has invalid PR URL '{pr_url}'") + + crate = front_matter.get("crate", "") + if crate and not re.match(r"^[a-z0-9][a-z0-9-]*$", crate): + raise FragmentError(f"{path} has invalid crate '{crate}'") + + body = "\n".join(lines[closing + 1 :]).strip() + if not body: + raise FragmentError(f"{path} must have a non-empty summary body") + + pr_number = int(pr_url.rstrip("/").split("/")[-1]) + return { + "path": str(path), + "filename": path.name, + "kind": kind, + "pr_url": pr_url, + "crate": crate, + "body": body, + "pr_number": pr_number, + } + + +def normalize_summary(text: str) -> str: + return " ".join(text.split()) + + +def make_bullet(fragment: dict[str, str]) -> tuple[str, str]: + kind = fragment["kind"] + section = "change" if kind == "breaking" else kind + summary = normalize_summary(fragment["body"]) + if kind == "breaking" and not summary.startswith("[BREAKING]"): + summary = f"[BREAKING] {summary}" + if fragment["crate"]: + summary = f"**{fragment['crate']}**: {summary}" + summary = summary.rstrip(".") + pr_number = fragment["pr_number"] + pr_url = fragment["pr_url"] + return section, f"- {summary} ([#{pr_number}]({pr_url}))." + + +def fragment_sort_key(fragment: dict[str, str]) -> tuple[int, str]: + # Keep batched changelog entries deterministic across repeated release-prep runs. + return fragment["pr_number"], fragment["filename"] + + +def collect_fragments() -> list[dict[str, str]]: + paths = sorted(UNRELEASED_DIR.glob("*.md")) + if not paths: + raise FragmentError(f"No fragments found in {UNRELEASED_DIR}") + fragments = [parse_fragment(path) for path in paths] + fragments.sort(key=fragment_sort_key) + return fragments + + +def validate_fragments(paths: list[pathlib.Path]) -> list[dict[str, str]]: + if not paths: + raise FragmentError("No fragments were provided for validation") + return [parse_fragment(path) for path in paths] + + +def build_generated_sections(fragments: list[dict[str, str]]) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {key: [] for key in SECTION_ORDER} + for fragment in fragments: + section, bullet = make_bullet(fragment) + sections[section].append(bullet) + return sections + + +def split_version_sections(changelog: str, version: str) -> tuple[str, str | None, str]: + pattern = re.compile( + rf"^## (?Pv?){re.escape(version)} \((?P[^)]+)\)\s*$", + re.MULTILINE, + ) + match = pattern.search(changelog) + if not match: + return changelog, None, "" + + start = match.start() + next_match = re.compile(r"^## ", re.MULTILINE).search(changelog, match.end()) + end = next_match.start() if next_match else len(changelog) + return changelog[:start], changelog[start:end], changelog[end:] + + +def insert_bullets(section_text: str, header: str, bullets: list[str]) -> str: + if not bullets: + return section_text + + alias_pattern = re.compile( + rf"^#### (?P
{re.escape(header)}|Bug Fixes)\s*$" if header == "Fixes" else rf"^#### (?P
{re.escape(header)})\s*$", + re.MULTILINE, + ) + match = alias_pattern.search(section_text) + block = "\n".join(bullets) + "\n" + + if match: + insert_at = match.end() + next_heading = re.compile(r"^#### ", re.MULTILINE).search(section_text, insert_at) + end = next_heading.start() if next_heading else len(section_text) + existing_block = section_text[insert_at:end] + if existing_block and not existing_block.startswith("\n"): + block = "\n" + block + return section_text[:end] + block + section_text[end:] + + suffix = section_text.rstrip() + if suffix: + suffix += "\n\n" + suffix += f"#### {header}\n\n" + "\n".join(bullets) + "\n" + return suffix + "\n" + + +def update_existing_section(existing: str, version: str, date: str, generated: dict[str, list[str]]) -> str: + heading_pattern = re.compile( + rf"^(## (?Pv?){re.escape(version)}) \((?P[^)]+)\)\s*$", + re.MULTILINE, + ) + match = heading_pattern.search(existing) + if not match: + raise FragmentError(f"Could not find the {version} section heading to update") + + prefix = match.group("prefix") + updated = heading_pattern.sub(rf"## {prefix}{version} ({date})", existing, count=1) + for key in SECTION_ORDER: + updated = insert_bullets(updated, SECTION_HEADERS[key], generated[key]) + return updated.rstrip() + "\n\n" + + +def create_new_section(version: str, date: str, generated: dict[str, list[str]]) -> str: + lines = [f"## {version} ({date})", ""] + for key in SECTION_ORDER: + bullets = generated[key] + if not bullets: + continue + lines.append(f"#### {SECTION_HEADERS[key]}") + lines.append("") + lines.extend(bullets) + lines.append("") + return "\n".join(lines).rstrip() + "\n\n" + + +def update_changelog(version: str, date: str, generated: dict[str, list[str]]) -> None: + changelog = CHANGELOG_PATH.read_text(encoding="utf-8") + before, existing, after = split_version_sections(changelog, version) + + if existing is not None: + updated_section = update_existing_section(existing, version, date, generated) + new_changelog = before + updated_section + after.lstrip("\n") + else: + header_match = re.match(r"^# Changelog\s*\n+", changelog) + if not header_match: + raise FragmentError(f"{CHANGELOG_PATH} must start with '# Changelog'") + new_section = create_new_section(version, date, generated) + new_changelog = changelog[: header_match.end()] + new_section + changelog[header_match.end() :] + + CHANGELOG_PATH.write_text(new_changelog, encoding="utf-8") + + +def flush_archive() -> None: + ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) + + for path in ARCHIVE_DIR.iterdir(): + if path.name.startswith("."): + continue + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + else: + raise FragmentError(f"Unsupported archive entry: {path}") + + +def archive_fragments(version: str, fragments: list[dict[str, str]]) -> None: + # Keep only the current release batch under .changes/archive/. + flush_archive() + destination = ARCHIVE_DIR / version + destination.mkdir(parents=True, exist_ok=True) + + for fragment in fragments: + source = pathlib.Path(fragment["path"]) + target = destination / source.name + if target.exists(): + raise FragmentError(f"Archive target already exists: {target}") + shutil.move(str(source), str(target)) + + +def main() -> int: + args = parse_args() + try: + if args.validate_fragment: + fragments = validate_fragments([pathlib.Path(path) for path in args.validate_fragment]) + print(f"Validated {len(fragments)} changelog fragment(s).") + return 0 + + fragments = collect_fragments() + generated = build_generated_sections(fragments) + update_changelog(args.version, args.date, generated) + archive_fragments(args.version, fragments) + except FragmentError as exc: + print(exc, file=sys.stderr) + return 1 + + print(f"Prepared changelog release for {args.version} using {len(fragments)} fragment(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_prepare_changelog_release.py b/scripts/tests/test_prepare_changelog_release.py new file mode 100644 index 0000000000..5f72cb422e --- /dev/null +++ b/scripts/tests/test_prepare_changelog_release.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile +import unittest + + +SCRIPT_PATH = pathlib.Path(__file__).resolve().parents[1] / "prepare-changelog-release.py" +SPEC = importlib.util.spec_from_file_location("prepare_changelog_release", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def write_fragment(path: pathlib.Path, front_matter: str, body: str) -> None: + path.write_text(f"---\n{front_matter}\n---\n\n{body}\n", encoding="utf-8") + + +class PrepareChangelogReleaseTests(unittest.TestCase): + def test_parse_fragment_accepts_quotes_comments_and_extra_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + fragment = pathlib.Path(tmp_dir) / "123.fix.md" + write_fragment( + fragment, + "\n".join( + [ + "# generated by a helper", + 'kind: "fix"', + "note: internal metadata", + "pr: 'https://github.com/0xMiden/miden-vm/pull/123'", + "crate: miden-processor", + ] + ), + "Fix a user-facing behavior in one concise sentence.", + ) + + parsed = MODULE.parse_fragment(fragment) + + self.assertEqual(parsed["kind"], "fix") + self.assertEqual(parsed["crate"], "miden-processor") + self.assertEqual(parsed["pr_number"], 123) + self.assertEqual( + parsed["body"], "Fix a user-facing behavior in one concise sentence." + ) + + def test_collect_fragments_sorts_by_pr_number_then_filename(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + unreleased = pathlib.Path(tmp_dir) + write_fragment( + unreleased / "10.fix.md", + "kind: fix\npr: https://github.com/0xMiden/miden-vm/pull/10", + "Third in sort order.", + ) + write_fragment( + unreleased / "2.change.md", + "kind: change\npr: https://github.com/0xMiden/miden-vm/pull/2", + "First in sort order.", + ) + write_fragment( + unreleased / "2.fix.md", + "kind: fix\npr: https://github.com/0xMiden/miden-vm/pull/2", + "Second in sort order.", + ) + + original_unreleased_dir = MODULE.UNRELEASED_DIR + MODULE.UNRELEASED_DIR = unreleased + try: + fragments = MODULE.collect_fragments() + finally: + MODULE.UNRELEASED_DIR = original_unreleased_dir + + self.assertEqual( + [fragment["filename"] for fragment in fragments], + ["2.change.md", "2.fix.md", "10.fix.md"], + ) + + def test_archive_fragments_flushes_old_batches_and_keeps_gitkeep(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = pathlib.Path(tmp_dir) + archive_root = root / "archive" + archive_root.mkdir() + (archive_root / ".gitkeep").write_text("", encoding="utf-8") + (archive_root / "0.22.0").mkdir() + (archive_root / "0.22.0" / "stale.fix.md").write_text("stale", encoding="utf-8") + source = root / "12.fix.md" + write_fragment( + source, + "kind: fix\npr: https://github.com/0xMiden/miden-vm/pull/12", + "Archived after release prep.", + ) + fragments = [MODULE.parse_fragment(source)] + + original_archive_dir = MODULE.ARCHIVE_DIR + MODULE.ARCHIVE_DIR = archive_root + try: + MODULE.archive_fragments("0.23.0", fragments) + finally: + MODULE.ARCHIVE_DIR = original_archive_dir + + self.assertFalse(source.exists()) + self.assertTrue((archive_root / ".gitkeep").exists()) + self.assertFalse((archive_root / "0.22.0").exists()) + self.assertTrue((archive_root / "0.23.0" / "12.fix.md").exists()) + + +if __name__ == "__main__": + unittest.main()