diff --git a/.github/actions/release-doc-sync/action.yml b/.github/actions/release-doc-sync/action.yml new file mode 100644 index 0000000..af7fc8b --- /dev/null +++ b/.github/actions/release-doc-sync/action.yml @@ -0,0 +1,138 @@ +# ============================================================================= +# Release-doc-sync composite action +# ============================================================================= +# +# Reusable action invoked by tool-repo `release.yml` workflows immediately +# after the version bump and immediately before the release commit. Its job +# is to keep CHANGELOG.md, CLAUDE.md, and ROADMAP.md in sync with the new +# plugin.json version so that doc-consistency tests stay green after every +# auto-release. +# +# What it does: +# 1. Checks out THIS action's repo (TMHSDigital/Developer-Tools-Directory) +# at the pinned ref so consuming repos do not need to vendor the sync +# script. Mirrors the drift-check@v1.7 pattern. +# 2. Sets up Python and runs scripts/release_doc_sync/sync.py against the +# caller's working tree. +# 3. The script edits, in-place, the three doc files when present: +# - CHANGELOG.md: prepends `## [X.Y.Z] - YYYY-MM-DD` stub section +# pointing to the GitHub release notes +# - CLAUDE.md: updates `**Version:** X.Y.Z` line and any +# `vOLD` / `(vOLD)` mentions to the new version +# - ROADMAP.md: updates the `**Current:** vX.Y.Z` line only; +# table rows and `(current)` markers are left +# untouched (per standards/versioning.md, patch +# releases do not get themed roadmap rows) +# 4. Surfaces a `changed` boolean and a space-separated `files-changed` +# list as outputs so the caller can branch on them or include them in +# the release commit message. +# +# Pinning rule: tool repos MUST consume this action via @v1.0 (or a SHA), +# never @main. The meta-repo's tag pipeline maintains the v1.0 tag pointing +# at the latest 1.x.y. See DTD#14 for the floating-tag automation +# follow-up. The drift-check@v1.7 lessons apply: @main from a tool repo +# means every meta-repo PR can break every tool-repo release. +# ============================================================================= + +name: 'Release doc sync' +description: 'Align CHANGELOG.md, CLAUDE.md, and ROADMAP.md with the new plugin.json version after an auto-release.' +author: 'TMHSDigital' + +inputs: + plugin-version: + description: 'New plugin version after the bump (semver, no v prefix). Required.' + required: true + previous-version: + description: 'Previous plugin version (semver, no v prefix). Used to scope CLAUDE.md replacements so unrelated version mentions are not mangled. Required.' + required: true + repository: + description: 'owner/repo used to construct the GitHub release-notes URL in the CHANGELOG entry. When unset (default), the action resolves to github.repository at runtime (which is what every tool repo wants).' + required: false + default: '' + release-date: + description: 'YYYY-MM-DD date stamp for the new CHANGELOG header. Defaults to today (UTC) at action runtime.' + required: false + default: '' + python-version: + description: 'Python interpreter for the sync script.' + required: false + default: '3.11' + meta-repo-ref: + description: 'git ref of TMHSDigital/Developer-Tools-Directory to use for the sync script. Defaults to v1.0 (latest 1.x.y).' + required: false + default: 'v1.0' + caller-path: + description: 'Path inside GITHUB_WORKSPACE that points at the caller checkout. Defaults to "." (root).' + required: false + default: '.' + +outputs: + changed: + description: 'true if at least one doc file was modified, false if every file was already aligned or absent.' + value: ${{ steps.run.outputs.changed }} + files-changed: + description: 'Space-separated list of files modified, relative to the caller checkout. Empty when changed=false.' + value: ${{ steps.run.outputs.files-changed }} + changelog-action: + description: 'One of: inserted, idempotent, missing. Reflects what happened to CHANGELOG.md.' + value: ${{ steps.run.outputs.changelog-action }} + claude-action: + description: 'One of: updated, idempotent, missing.' + value: ${{ steps.run.outputs.claude-action }} + roadmap-action: + description: 'One of: updated, idempotent, missing.' + value: ${{ steps.run.outputs.roadmap-action }} + +runs: + using: 'composite' + steps: + # The sync script lives in this repo. Whether the caller is the meta-repo + # itself or a tool repo, we always need the script at a known path. Use a + # dedicated subdirectory so we do not clobber the caller's own checkout + # (which lives at GITHUB_WORKSPACE). + - name: Checkout release-doc-sync script + uses: actions/checkout@v5 + with: + repository: TMHSDigital/Developer-Tools-Directory + ref: ${{ inputs.meta-repo-ref }} + path: .release-doc-sync + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Run release-doc-sync + id: run + shell: bash + working-directory: .release-doc-sync + env: + PLUGIN_VERSION: ${{ inputs.plugin-version }} + PREVIOUS_VERSION: ${{ inputs.previous-version }} + REPOSITORY: ${{ inputs.repository != '' && inputs.repository || github.repository }} + RELEASE_DATE: ${{ inputs.release-date }} + CALLER_PATH: ${{ github.workspace }}/${{ inputs.caller-path }} + run: | + set +e + EXTRA_ARGS=() + if [ -n "$RELEASE_DATE" ]; then + EXTRA_ARGS+=(--date "$RELEASE_DATE") + fi + + python scripts/release_doc_sync/sync.py \ + --repo-path "$CALLER_PATH" \ + --plugin-version "$PLUGIN_VERSION" \ + --previous-version "$PREVIOUS_VERSION" \ + --repository "$REPOSITORY" \ + --github-output \ + "${EXTRA_ARGS[@]}" + RC=$? + + if [ "$RC" -ne 0 ] && [ "$RC" -ne 1 ]; then + echo "::error::release-doc-sync failed with exit code $RC" + exit "$RC" + fi + + # rc=0 (no changes) and rc=1 (changes made) are both successful from + # the action's perspective. The action only fails on rc>=2 (tool error). + exit 0 diff --git a/README.md b/README.md index 8749062..f99d15f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Documented conventions for building new developer tools. All docs in [`standards | [README Template](standards/readme-template.md) | Standard README structure and required sections | | [AGENTS.md Template](standards/agents-template.md) | AI agent guidance file structure | | [Versioning](standards/versioning.md) | Semver management and automated release flow | +| [Release-doc-sync](standards/release-doc-sync.md) | Composite action contract for keeping CHANGELOG, CLAUDE, and ROADMAP in sync after auto-release | | [Testing](standards/testing.md) | Test frameworks, coverage bar, and CI wiring | | [Skills](standards/skills.md) | `SKILL.md` structure and frontmatter | | [Rules](standards/rules.md) | `.mdc` structure, globs, and the secrets rule pattern | diff --git a/VERSION b/VERSION index 5849151..27f9cd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.5 \ No newline at end of file +1.8.0 diff --git a/scripts/release_doc_sync/__init__.py b/scripts/release_doc_sync/__init__.py new file mode 100644 index 0000000..ee086f2 --- /dev/null +++ b/scripts/release_doc_sync/__init__.py @@ -0,0 +1,14 @@ +"""Release-doc-sync: keep CHANGELOG.md, CLAUDE.md, and ROADMAP.md aligned with +the new plugin.json version after an auto-release. + +Public surface is intentionally narrow. The action invokes ``sync.py`` as a +script. Tests import ``sync_repo`` and the per-file helpers directly. +""" + +from .sync import ( # noqa: F401 + SyncResult, + sync_changelog, + sync_claude, + sync_repo, + sync_roadmap, +) diff --git a/scripts/release_doc_sync/sync.py b/scripts/release_doc_sync/sync.py new file mode 100644 index 0000000..4d4e5e8 --- /dev/null +++ b/scripts/release_doc_sync/sync.py @@ -0,0 +1,481 @@ +"""Release-doc-sync (Phase 2 Session D-2 of DTD#5). + +This script is invoked by the ``release-doc-sync`` composite action from each +tool repo's ``release.yml`` workflow, between the ``plugin.json`` version +bump and the release commit. It edits, in-place, the three doc files that +contain version references the release pipeline does not otherwise +maintain: + +* ``CHANGELOG.md`` -- prepends a stub ``## [X.Y.Z] - YYYY-MM-DD`` section +* ``CLAUDE.md`` -- updates the canonical ``**Version:**`` line and any + ``vOLD`` / ``(vOLD)`` mentions +* ``ROADMAP.md`` -- updates only the ``**Current:** vX.Y.Z`` line; the + roadmap table and ``(current)`` markers are left + alone because per ``standards/versioning.md`` patch + releases do not get themed roadmap rows + +Design contract: + +* Every edit is idempotent. Running the script on already-aligned docs is a + no-op and leaves files byte-identical. +* Every edit is local: a missing file logs a warning and skips, never + fails. This keeps tool repos that lack one of the three docs from + blocking their releases. +* The script never touches ``plugin.json`` or ``README.md`` -- those are + owned by the existing ``release.yml`` step. +* The script never touches ```` markers -- + those belong to the drift-checker (DTD#1). + +Exit codes (matches drift-check@v1.7 conventions adapted for "no findings" +semantics): + +* ``0`` -- ran successfully, no files changed (already aligned, or all absent) +* ``1`` -- ran successfully, at least one file changed +* ``2`` -- tool error (bad args, unreadable files, malformed inputs) + +The ``rc=1`` "made a change" signal is informational; the calling action +treats both ``rc=0`` and ``rc=1`` as success and only fails on ``rc>=2``. +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + + +# --------------------------------------------------------------------------- +# Result types +# --------------------------------------------------------------------------- + + +@dataclass +class FileResult: + """Outcome for a single doc file.""" + + path: Path + action: str # one of: inserted, updated, idempotent, missing + detail: str = "" + + @property + def changed(self) -> bool: + return self.action in {"inserted", "updated"} + + +@dataclass +class SyncResult: + """Aggregate outcome for a sync_repo() invocation.""" + + changelog: FileResult + claude: FileResult + roadmap: FileResult + files_changed: List[Path] = field(default_factory=list) + + @property + def changed(self) -> bool: + return any(r.changed for r in (self.changelog, self.claude, self.roadmap)) + + +# --------------------------------------------------------------------------- +# CHANGELOG.md +# --------------------------------------------------------------------------- + + +_CHANGELOG_VERSION_RE = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE) +_CHANGELOG_FIRST_RELEASE_RE = re.compile(r"^##\s*\[", re.MULTILINE) + + +def sync_changelog( + path: Path, + *, + plugin_version: str, + repository: str, + release_date: str, +) -> FileResult: + """Insert a stub release section for ``plugin_version`` if absent. + + The stub is intentionally minimal -- a header line plus a one-line + pointer to the GitHub release notes. Curated narrative belongs in + GitHub Releases (which ``release.yml`` already generates with + ``--generate-notes`` / release-drafter). + + Stub shape (matches Phase 1 Q1 decision):: + + ## [X.Y.Z] - YYYY-MM-DD + + See [release notes](https://github.com//releases/tag/vX.Y.Z) for details. + + Idempotency: if ``## [X.Y.Z]`` already appears anywhere in the file, + the function returns ``idempotent`` without touching the file. + + Insertion point: immediately before the first existing ``## [`` release + section. If no release sections exist yet, append after the file's + existing content (one trailing blank line is normalized). + + Footer link refs (``[X.Y.Z]: ``) are intentionally NOT auto-added. + Steam's Keep-a-Changelog format uses them, but maintaining the link + table requires either upserting alphabetical-by-version order or + relying on Markdown allowing duplicates -- both fragile. The Steam + test only requires ``[X.Y.Z]`` substring, which the header alone + satisfies. + """ + if not path.is_file(): + return FileResult(path=path, action="missing") + + text = path.read_text(encoding="utf-8") + + if f"[{plugin_version}]" in text: + return FileResult( + path=path, + action="idempotent", + detail=f"version {plugin_version} already present", + ) + + release_url = f"https://github.com/{repository}/releases/tag/v{plugin_version}" + stub = ( + f"## [{plugin_version}] - {release_date}\n" + f"\n" + f"See [release notes]({release_url}) for details.\n" + f"\n" + ) + + insertion_match = _CHANGELOG_FIRST_RELEASE_RE.search(text) + if insertion_match is not None: + insertion_idx = insertion_match.start() + new_text = text[:insertion_idx] + stub + text[insertion_idx:] + else: + if not text.endswith("\n"): + text = text + "\n" + if not text.endswith("\n\n"): + text = text + "\n" + new_text = text + stub + + path.write_text(new_text, encoding="utf-8") + return FileResult( + path=path, + action="inserted", + detail=f"prepended ## [{plugin_version}] section", + ) + + +# --------------------------------------------------------------------------- +# CLAUDE.md +# --------------------------------------------------------------------------- + + +# Match `**Version:** [v]X.Y.Z` (Docker convention) but capture only the +# value so we can rewrite it. The colon-space is required to avoid matching +# bold prose like `**Version:**: 1.0.0` or unrelated headings. +_CLAUDE_VERSION_LINE_RE = re.compile(r"(\*\*Version:\*\*\s+)v?(\d+\.\d+\.\d+)") + + +def sync_claude( + path: Path, + *, + plugin_version: str, + previous_version: str, +) -> FileResult: + """Update CLAUDE.md version references. + + Three patterns are handled, all idempotent: + + 1. ``**Version:** X.Y.Z`` line -- replaced with the new version. The + ``v`` prefix is preserved if it was originally present (so Docker's + ``**Version:** 1.0.0`` stays bare and a hypothetical + ``**Version:** v1.0.0`` keeps its prefix). + 2. ``vOLD`` substring -- replaced with ``vNEW``. Scoped to the + previous version so unrelated version mentions (e.g., a roadmap + reference to ``v0.1.0`` in CLAUDE.md prose) are preserved. + 3. The ``vOLD`` rewrite naturally covers ``(vOLD)`` Steam-style + parentheticals since ``v`` is part of the match. + + Explicit non-targets (regression guards): + + * ```` markers belong to the drift + checker (DTD#1). They are not version-prefixed with ``v`` so the + ``vOLD`` regex cannot match them, and they do not contain + ``**Version:**``. Defended by ``test_claude_does_not_touch_standards_version``. + * Bare ``OLD`` substrings (e.g., the literal string ``1.0.0``) are + NOT replaced. Doing so would mangle CHANGELOG-style references + buried inside CLAUDE.md or quoted command output. + """ + if not path.is_file(): + return FileResult(path=path, action="missing") + + text = path.read_text(encoding="utf-8") + original = text + + # Pattern 1: **Version:** line. Preserves v-prefix presence. + def _rewrite_version_line(match: re.Match) -> str: + prefix = match.group(1) + captured = match.group(2) + had_v = match.group(0)[len(prefix)] == "v" + new_value = f"v{plugin_version}" if had_v else plugin_version + if captured == plugin_version: + return match.group(0) + return f"{prefix}{new_value}" + + text = _CLAUDE_VERSION_LINE_RE.sub(_rewrite_version_line, text) + + # Pattern 2 & 3: vOLD -> vNEW. Use a negative lookahead instead of \b + # because \b matches between '0' and '-', which would incorrectly rewrite + # 'v1.0.0-beta' when previous='1.0.0'. The lookahead refuses any further + # version-character follower (digit, dot, dash, letter), so 'v1.0.0' only + # matches when it stands alone as a complete version token. + if previous_version != plugin_version: + v_old_re = re.compile(rf"v{re.escape(previous_version)}(?![\w.\-])") + text = v_old_re.sub(f"v{plugin_version}", text) + + if text == original: + return FileResult( + path=path, + action="idempotent", + detail="no version patterns matched or already aligned", + ) + + path.write_text(text, encoding="utf-8") + return FileResult( + path=path, + action="updated", + detail=f"rewrote version references {previous_version} -> {plugin_version}", + ) + + +# --------------------------------------------------------------------------- +# ROADMAP.md +# --------------------------------------------------------------------------- + + +# Capture `**Current:** vX.Y.Z`. Group 1 = leading prefix (preserved), +# group 2 = the v-prefixed version. Tolerates `v` being absent, but always +# emits the canonical `**Current:** vNEW` form (matches Steam's +# test_current_line_version regex). +_ROADMAP_CURRENT_LINE_RE = re.compile(r"(\*\*Current:\*\*\s+)v?(\d+\.\d+\.\d+)") + + +def sync_roadmap(path: Path, *, plugin_version: str) -> FileResult: + """Update ``**Current:** vX.Y.Z`` line in ROADMAP.md. + + Explicit non-targets: + + * The roadmap table (``| vX.Y.Z | Theme | ... |``). Patch releases do + not get themed roadmap rows -- see ``standards/versioning.md`` and + DTD#5 for the policy. Auto-bumping a patch row would invent a fake + theme, so the action leaves the table alone. + * ``(current)`` markers anywhere in the file. The marker tracks the + currently-released **theme**, which is a human-curated minor/major + decision. The corresponding doc-consistency tests in Docker and + Steam are being relaxed in Phase 2d. + + Idempotency: if no ``**Current:**`` line exists, returns + ``idempotent`` (Docker-style ROADMAPs use ``**vX.Y.Z** - ...`` + without a ``**Current:**`` label). If the line already names + ``plugin_version``, also returns ``idempotent``. + """ + if not path.is_file(): + return FileResult(path=path, action="missing") + + text = path.read_text(encoding="utf-8") + match = _ROADMAP_CURRENT_LINE_RE.search(text) + + if match is None: + return FileResult( + path=path, + action="idempotent", + detail="no **Current:** line present", + ) + + if match.group(2) == plugin_version: + return FileResult( + path=path, + action="idempotent", + detail=f"**Current:** already at v{plugin_version}", + ) + + new_text = ( + text[: match.start()] + + f"{match.group(1)}v{plugin_version}" + + text[match.end() :] + ) + path.write_text(new_text, encoding="utf-8") + return FileResult( + path=path, + action="updated", + detail=f"**Current:** {match.group(2)} -> {plugin_version}", + ) + + +# --------------------------------------------------------------------------- +# Top-level orchestration +# --------------------------------------------------------------------------- + + +def sync_repo( + repo_path: Path, + *, + plugin_version: str, + previous_version: str, + repository: str, + release_date: str, +) -> SyncResult: + """Run all three syncs and aggregate results. + + Files are looked up at ``repo_path/CHANGELOG.md``, ``repo_path/CLAUDE.md``, + ``repo_path/ROADMAP.md``. Tool repos that nest these files under a + different layout (none in the current ecosystem) would need a different + integration; the simple flat-root layout is the standard. + """ + changelog = sync_changelog( + repo_path / "CHANGELOG.md", + plugin_version=plugin_version, + repository=repository, + release_date=release_date, + ) + claude = sync_claude( + repo_path / "CLAUDE.md", + plugin_version=plugin_version, + previous_version=previous_version, + ) + roadmap = sync_roadmap( + repo_path / "ROADMAP.md", + plugin_version=plugin_version, + ) + + files_changed = [r.path for r in (changelog, claude, roadmap) if r.changed] + return SyncResult( + changelog=changelog, + claude=claude, + roadmap=roadmap, + files_changed=files_changed, + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="release-doc-sync", + description=( + "Align CHANGELOG.md, CLAUDE.md, and ROADMAP.md with the new " + "plugin.json version after an auto-release." + ), + ) + p.add_argument( + "--repo-path", + required=True, + type=Path, + help="Path to the tool-repo working tree (where CHANGELOG.md etc. live).", + ) + p.add_argument( + "--plugin-version", + required=True, + help="New plugin version, semver only (no 'v' prefix). Example: 1.2.1", + ) + p.add_argument( + "--previous-version", + required=True, + help="Previous plugin version, semver only. Example: 1.2.0", + ) + p.add_argument( + "--repository", + required=True, + help="owner/repo for constructing the GitHub release URL in CHANGELOG.", + ) + p.add_argument( + "--date", + default=None, + help="YYYY-MM-DD release date for CHANGELOG header. Defaults to today UTC.", + ) + p.add_argument( + "--github-output", + action="store_true", + help="Write outputs to $GITHUB_OUTPUT in addition to stdout.", + ) + return p.parse_args(argv) + + +def _validate_args(ns: argparse.Namespace) -> None: + if not _SEMVER_RE.match(ns.plugin_version): + raise SystemExit( + f"::error::--plugin-version must be MAJOR.MINOR.PATCH; got {ns.plugin_version!r}" + ) + if not _SEMVER_RE.match(ns.previous_version): + raise SystemExit( + f"::error::--previous-version must be MAJOR.MINOR.PATCH; got {ns.previous_version!r}" + ) + if "/" not in ns.repository: + raise SystemExit( + f"::error::--repository must be owner/name; got {ns.repository!r}" + ) + if not ns.repo_path.is_dir(): + raise SystemExit( + f"::error::--repo-path does not exist or is not a directory: {ns.repo_path}" + ) + + +def _print_result(result: SyncResult) -> None: + for fr in (result.changelog, result.claude, result.roadmap): + rel = fr.path.name + line = f"{fr.action:<11} {rel}" + if fr.detail: + line += f" ({fr.detail})" + print(line) + print() + if result.changed: + names = " ".join(p.name for p in result.files_changed) + print(f"changed: true ({names})") + else: + print("changed: false") + + +def _emit_github_output(result: SyncResult) -> None: + out_path = os.environ.get("GITHUB_OUTPUT") + if not out_path: + return + with open(out_path, "a", encoding="utf-8") as f: + f.write(f"changed={'true' if result.changed else 'false'}\n") + f.write( + "files-changed=" + " ".join(p.name for p in result.files_changed) + "\n" + ) + f.write(f"changelog-action={result.changelog.action}\n") + f.write(f"claude-action={result.claude.action}\n") + f.write(f"roadmap-action={result.roadmap.action}\n") + + +def main(argv: Optional[List[str]] = None) -> int: + ns = _parse_args(argv) + _validate_args(ns) + + release_date = ns.date or _dt.datetime.now(_dt.timezone.utc).date().isoformat() + + try: + result = sync_repo( + ns.repo_path, + plugin_version=ns.plugin_version, + previous_version=ns.previous_version, + repository=ns.repository, + release_date=release_date, + ) + except OSError as exc: + print(f"::error::I/O failure during sync: {exc}", file=sys.stderr) + return 2 + + _print_result(result) + if ns.github_output: + _emit_github_output(result) + + return 1 if result.changed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/standards/README.md b/standards/README.md index 1a1ef84..2a28518 100644 --- a/standards/README.md +++ b/standards/README.md @@ -14,6 +14,7 @@ Standards and conventions for building Cursor IDE plugins, MCP servers, and deve | [README Template](readme-template.md) | Standard README structure and required sections | | [AGENTS.md Template](agents-template.md) | AI agent guidance file structure | | [Versioning](versioning.md) | Semver management and release flow | +| [Release-doc-sync](release-doc-sync.md) | Composite action contract for keeping CHANGELOG, CLAUDE, and ROADMAP in sync after auto-release | | [Testing](testing.md) | Test frameworks, minimum coverage bar, CI wiring | | [Skills](skills.md) | `SKILL.md` structure, frontmatter, and conventions | | [Rules](rules.md) | `.mdc` structure, globs, and the secrets rule pattern | diff --git a/standards/release-doc-sync.md b/standards/release-doc-sync.md new file mode 100644 index 0000000..05a70bd --- /dev/null +++ b/standards/release-doc-sync.md @@ -0,0 +1,116 @@ +# Release-doc-sync + +The `release-doc-sync` composite action keeps `CHANGELOG.md`, `CLAUDE.md`, and `ROADMAP.md` aligned with the new `plugin.json` version on every auto-release. It exists because `release.yml` only edits `plugin.json` and the README badge today, which lets doc-consistency tests fail on every PR after a release until the docs are manually updated. This document is the contract that tool-repo `release.yml` workflows consume. + +## Source + +- Action: `.github/actions/release-doc-sync/action.yml` +- Sync logic: `scripts/release_doc_sync/sync.py` +- Tests: `tests/test_release_doc_sync.py` + +## Pinning + +Tool repos MUST consume this action via `@v1.0` (or a full commit SHA), never `@main`. The pinning rule matches the `drift-check@v1.7` precedent in this repo: `@main` from a tool repo means every meta-repo PR can break every tool-repo release. The `v1.0` floating tag points at the latest `1.x.y` of this repo and is updated by the maintainer when a relevant change ships. Floating-tag automation is tracked in DTD#14. + +```yaml +uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@v1.0 +``` + +## Inputs + +| Input | Required | Default | Purpose | +| --- | --- | --- | --- | +| `plugin-version` | yes | -- | New plugin version after the bump (semver, no `v` prefix). Example: `1.2.1`. | +| `previous-version` | yes | -- | Previous plugin version. Used to scope CLAUDE.md replacements so unrelated version mentions are not mangled. | +| `repository` | no | `${{ github.repository }}` | `owner/repo` for constructing the GitHub release-notes URL in the CHANGELOG entry. | +| `release-date` | no | today (UTC) | `YYYY-MM-DD` date stamp for the new CHANGELOG header. | +| `python-version` | no | `3.11` | Python interpreter for the sync script. | +| `meta-repo-ref` | no | `v1.0` | git ref of `TMHSDigital/Developer-Tools-Directory` to use for the sync script. | +| `caller-path` | no | `.` | Path inside `GITHUB_WORKSPACE` that points at the caller checkout. | + +## Outputs + +| Output | Values | +| --- | --- | +| `changed` | `true` if at least one doc file was modified, `false` otherwise. | +| `files-changed` | Space-separated list of file basenames modified. Empty when `changed=false`. | +| `changelog-action` | One of `inserted`, `idempotent`, `missing`. | +| `claude-action` | One of `updated`, `idempotent`, `missing`. | +| `roadmap-action` | One of `updated`, `idempotent`, `missing`. | + +## What the action edits + +### `CHANGELOG.md` + +Prepends a stub section at the top of the releases list: + +```markdown +## [X.Y.Z] - YYYY-MM-DD + +See [release notes](https://github.com///releases/tag/vX.Y.Z) for details. +``` + +The stub is intentionally minimal. Curated narrative belongs in GitHub Releases (which `release.yml` already generates). Insertion point is immediately before the first existing `## [` section; if there are no prior release sections, the stub is appended after the file's preamble. If `[X.Y.Z]` already appears anywhere in the file, the action no-ops. + +### `CLAUDE.md` + +Two patterns are rewritten, both idempotent: + +1. The canonical `**Version:** X.Y.Z` line (Docker convention) is replaced with the new version. The `v` prefix is preserved if it was originally present. +2. Any `vOLD` token (Steam-style prose mentions like `(v1.0.0)` and `The current release is v1.0.0`) is rewritten to `vNEW`. The match uses a strict lookahead so `v1.0.0` does NOT match inside `v1.0.0-beta` or `v1.0.01`. + +The action does NOT touch: + +- `` HTML comment markers. Those are owned by the drift checker (DTD#1) and represent a different concept (ecosystem standards version). +- Bare `OLD` substrings (e.g., the literal `1.0.0` without a `v` prefix or `**Version:**` label). Doing so would mangle CHANGELOG-style references buried inside CLAUDE.md or quoted command output. + +### `ROADMAP.md` + +Updates only the `**Current:** vX.Y.Z` line if present. Tool repos that use a different roadmap layout (e.g., Docker's bold-prefix style without a `**Current:**` label) get a no-op, which is intentional. The action explicitly does NOT touch: + +- The themed-release table. Patch releases do not get table rows per the policy in [`versioning.md`](versioning.md). +- `(current)` markers anywhere in the file. The marker tracks the currently-released theme, which is a human-curated minor/major decision. + +## Recommended integration in a tool-repo `release.yml` + +Insert one step between the existing `Update version files` step (which bumps `plugin.json` and the README badge) and the existing `Commit and tag` step (which runs `git add -A && git commit`). The `release-doc-sync` step's edits land in the working tree and are picked up by the same release commit: + +```yaml +- name: Update version files + # ... existing python block bumps plugin.json and README badge ... + +- name: Sync doc version references + if: steps.check.outputs.skip == 'false' + uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@v1.0 + with: + plugin-version: ${{ steps.new.outputs.version }} + previous-version: ${{ steps.current.outputs.version }} + +- name: Commit and tag + # unchanged: git add -A picks up the action's edits +``` + +The action does NOT do its own git operations, does NOT request a `github-token`, and does NOT call the GitHub API. All it does is read and write files in the caller's working tree. + +## Exit-code contract + +The Python script under the action follows the same convention as `drift-check`: + +| Exit code | Meaning | +| --- | --- | +| `0` | Ran successfully, no files changed (already aligned, or all absent). | +| `1` | Ran successfully, at least one file changed. | +| `2` | Tool error (bad args, unreadable files, malformed inputs). | + +The composite action treats both `0` and `1` as success and only fails the calling job on `>=2`. The `1` "made a change" signal is informational; consumers can branch on the `changed` output if they need to react. + +## Idempotency + +Every edit is idempotent at the file level. Running the action twice in a row leaves the second invocation as a pure no-op: the files are byte-identical to their post-first-run state. This guarantee is exercised by `test_second_run_is_pure_noop` and is what makes the action safe to wire into `release.yml` even when a release re-runs (manual `workflow_dispatch`, retried job, etc.). + +## Out of scope + +- `AGENTS.md` plugin-version stamping. The drift checker uses a different concept (`standards-version`) for AGENTS.md and adding plugin-version stamping needs a separate design discussion. +- Roadmap table row insertion. See [`versioning.md`](versioning.md) for why patch releases do not get rows; humans curate the table for minor/major releases. +- README badge updates. Already handled by the existing `Update version files` step in `release.yml`. +- Footer link references in CHANGELOG (`[X.Y.Z]: `). Steam's Keep-a-Changelog format uses them, but maintaining the link table requires either upserting alphabetical-by-version order or relying on Markdown allowing duplicates -- both fragile. The Steam doc-consistency test only requires the `[X.Y.Z]` substring, which the header alone satisfies. diff --git a/standards/versioning.md b/standards/versioning.md index b377da0..5743c5a 100644 --- a/standards/versioning.md +++ b/standards/versioning.md @@ -33,6 +33,12 @@ push to main | `feat:` or `feat(scope):` | **minor** (x.Y.0) | `feat: add new skill` | | Anything else | **patch** (x.y.Z) | `fix: handle null in lookup` | +### Patch releases and themed roadmap rows + +Patch releases (`x.y.Z`) do NOT get a row in `ROADMAP.md`'s themed-release table. The roadmap table tracks human-curated milestones with a theme line per row (e.g., `v0.12.0 - Niche, Scout, and Extras`); inventing a theme for a mechanical patch bump would dilute the table's signal. Patch releases are surfaced in `CHANGELOG.md` (one stub section per release) and in the `**Current:** vX.Y.Z` line at the top of `ROADMAP.md`, both of which are auto-maintained by the `release-doc-sync` composite action documented in [`release-doc-sync.md`](release-doc-sync.md). + +Minor and major releases SHOULD get a roadmap row, with the theme set during the planning of that release. The `release-doc-sync` action does not invent or move table rows for any bump type; humans curate the table and the action keeps the surrounding doc surface (CHANGELOG stub, current-version line, CLAUDE.md `**Version:**` line) in sync after the auto-bump runs. + ## What NOT to Do - **Do not manually edit the version in `plugin.json`.** The release workflow will overwrite it. diff --git a/tests/test_release_doc_sync.py b/tests/test_release_doc_sync.py new file mode 100644 index 0000000..8ab5451 --- /dev/null +++ b/tests/test_release_doc_sync.py @@ -0,0 +1,706 @@ +"""Unit tests for ``scripts/release_doc_sync/sync.py``. + +Coverage targets: + +* CHANGELOG.md insertion at the right position (before first ``## [`` section) +* CHANGELOG.md idempotency (``## [X.Y.Z]`` already present) +* CHANGELOG.md with no existing release sections (append at end) +* CLAUDE.md updates Docker-style ``**Version:** X.Y.Z`` line, preserves + v-prefix presence +* CLAUDE.md updates Steam-style ``vOLD`` and ``(vOLD)`` mentions in prose +* CLAUDE.md leaves ```` markers alone (DTD#1 + ownership boundary) +* CLAUDE.md does not mangle bare ``OLD`` substrings (regression guard) +* ROADMAP.md updates ``**Current:** vX.Y.Z`` line, preserves table content +* ROADMAP.md leaves Docker-style files (no ``**Current:**`` label) alone +* ROADMAP.md does not move or rewrite ``(current)`` markers +* End-to-end: running sync_repo twice is a no-op the second time +* Missing files do not crash, return ``missing`` action +* CLI: exit code 0 when nothing changed, 1 when something changed, 2 on + bad args +* Composite action shape: action.yml parses, declares the documented + inputs/outputs, follows the drift-check pattern +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml") + +from tests.conftest import REPO_ROOT # noqa: E402 + +from scripts.release_doc_sync.sync import ( # noqa: E402 + SyncResult, + main, + sync_changelog, + sync_claude, + sync_repo, + sync_roadmap, +) + + +# --------------------------------------------------------------------------- +# Fixtures: real-world doc shapes from Docker and Steam plugin repos +# --------------------------------------------------------------------------- + + +DOCKER_CHANGELOG = """\ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-03-29 + +### Changed + +#### MCP Server - Enhanced Error Messages +- errorResponse now includes error type prefix. + +## [0.12.0] - 2026-03-29 + +### Added + +- niche tools. +""" + + +STEAM_CHANGELOG = """\ +# Changelog + +All notable changes to Steam Developer Tools will be documented in this file. + +## [1.0.0] - 2026-03-29 + +### Added + +- 5 new MCP tools. +""" + + +CHANGELOG_NO_SECTIONS = """\ +# Changelog + +All notable changes will go here once we cut our first release. +""" + + +DOCKER_CLAUDE = """\ + + +# CLAUDE.md + +Project documentation for Claude Code. + +**Version:** 1.0.0 +**License:** CC-BY-NC-ND-4.0 +**Author:** TMHSDigital +""" + + +STEAM_CLAUDE = """\ + + +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## Project Overview + +Steam Developer Tools is a Cursor IDE plugin (v1.0.0) that integrates Steam APIs. + +The current release is v1.0.0 "Stable" - the production release. +""" + + +CLAUDE_NO_VERSION_AT_ALL = """\ +# CLAUDE.md + +Some text without a version reference at all. +""" + + +DOCKER_ROADMAP = """\ +# Roadmap + +> Docker Developer Tools plugin roadmap. + +## Current Status + +**v0.12.0** - Niche, Scout, and Extras release. + +## Release Plan + +| Version | Theme | Status | +| --- | --- | --- | +| v0.12.0 | Niche, Scout, and Extras | Released | +| v1.0.0 | Stable | (current) | +""" + + +STEAM_ROADMAP = """\ +# Roadmap + +Themed release plan toward v1.0.0. + +**Current:** v1.0.0 - 30 skills, 9 rules. + +**Status:** v1.0.0 "Stable" released. + +| Version | Theme | Total Skills | +| --- | --- | --- | +| v0.1.0 | - | 14 | +| v1.0.0 (current) | Stable | 30 | +""" + + +# --------------------------------------------------------------------------- +# CHANGELOG tests +# --------------------------------------------------------------------------- + + +class TestChangelog: + def test_inserts_stub_before_first_release(self, tmp_path: Path): + path = tmp_path / "CHANGELOG.md" + path.write_text(DOCKER_CHANGELOG, encoding="utf-8") + + result = sync_changelog( + path, + plugin_version="1.2.1", + repository="TMHSDigital/Docker-Developer-Tools", + release_date="2026-04-25", + ) + + assert result.action == "inserted" + assert result.changed is True + text = path.read_text(encoding="utf-8") + + # New stub appears + assert "## [1.2.1] - 2026-04-25" in text + assert ( + "See [release notes]" + "(https://github.com/TMHSDigital/Docker-Developer-Tools/releases/tag/v1.2.1)" + " for details." in text + ) + + # Inserted before the prior latest release + idx_new = text.index("## [1.2.1]") + idx_old = text.index("## [1.0.0]") + assert idx_new < idx_old + + # Pre-existing entries preserved verbatim + assert "errorResponse now includes error type prefix." in text + assert "## [0.12.0] - 2026-03-29" in text + + def test_steam_bracketed_substring_satisfied(self, tmp_path: Path): + path = tmp_path / "CHANGELOG.md" + path.write_text(STEAM_CHANGELOG, encoding="utf-8") + + sync_changelog( + path, + plugin_version="1.2.1", + repository="TMHSDigital/Steam-Cursor-Plugin", + release_date="2026-04-25", + ) + + text = path.read_text(encoding="utf-8") + assert "[1.2.1]" in text + + def test_idempotent_when_version_already_present(self, tmp_path: Path): + path = tmp_path / "CHANGELOG.md" + path.write_text(DOCKER_CHANGELOG, encoding="utf-8") + + first = sync_changelog( + path, + plugin_version="1.0.0", + repository="TMHSDigital/Docker-Developer-Tools", + release_date="2026-04-25", + ) + + assert first.action == "idempotent" + assert path.read_text(encoding="utf-8") == DOCKER_CHANGELOG + + def test_appends_when_no_release_sections_exist(self, tmp_path: Path): + path = tmp_path / "CHANGELOG.md" + path.write_text(CHANGELOG_NO_SECTIONS, encoding="utf-8") + + result = sync_changelog( + path, + plugin_version="0.1.0", + repository="TMHSDigital/New-Plugin", + release_date="2026-04-25", + ) + + assert result.action == "inserted" + text = path.read_text(encoding="utf-8") + assert text.startswith("# Changelog") + assert "## [0.1.0] - 2026-04-25" in text + # The original preamble survives. + assert "Some text without a version reference" not in text # never was there + assert "All notable changes will go here" in text + + def test_missing_file_returns_missing(self, tmp_path: Path): + path = tmp_path / "CHANGELOG.md" # not created + result = sync_changelog( + path, + plugin_version="1.2.1", + repository="TMHSDigital/Docker-Developer-Tools", + release_date="2026-04-25", + ) + assert result.action == "missing" + assert result.changed is False + assert not path.exists() + + +# --------------------------------------------------------------------------- +# CLAUDE tests +# --------------------------------------------------------------------------- + + +class TestClaude: + def test_updates_docker_version_line_preserves_no_v_prefix(self, tmp_path: Path): + path = tmp_path / "CLAUDE.md" + path.write_text(DOCKER_CLAUDE, encoding="utf-8") + + result = sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + assert result.action == "updated" + text = path.read_text(encoding="utf-8") + assert "**Version:** 1.2.1" in text + assert "**Version:** 1.0.0" not in text + + def test_updates_steam_prose_v_prefix_form(self, tmp_path: Path): + path = tmp_path / "CLAUDE.md" + path.write_text(STEAM_CLAUDE, encoding="utf-8") + + result = sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + assert result.action == "updated" + text = path.read_text(encoding="utf-8") + assert "(v1.2.1)" in text + assert "(v1.0.0)" not in text + # Steam test asserts: f"v{version}" in claude_text or version in claude_text + assert "v1.2.1" in text + + def test_does_not_touch_standards_version_marker(self, tmp_path: Path): + """Regression guard: the HTML comment marker is owned by the drift + checker (DTD#1) and uses a different concept (ecosystem standards + version, not plugin version). It must never be rewritten by this + action.""" + path = tmp_path / "CLAUDE.md" + path.write_text(DOCKER_CLAUDE, encoding="utf-8") + + sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + text = path.read_text(encoding="utf-8") + assert "" in text + + def test_does_not_mangle_bare_old_substring(self, tmp_path: Path): + """If CLAUDE.md happens to contain a bare ``1.0.0`` substring inside + a code block or quoted output, it must NOT be rewritten. Only + ``vOLD`` and ``**Version:** OLD`` patterns are in scope.""" + path = tmp_path / "CLAUDE.md" + content = ( + "# CLAUDE.md\n\n" + "**Version:** 1.0.0\n\n" + "Quoted from an upstream changelog: 'Released 1.0.0 in March 2024.'\n" + ) + path.write_text(content, encoding="utf-8") + + sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + text = path.read_text(encoding="utf-8") + assert "**Version:** 1.2.1" in text + # Bare '1.0.0' in the prose quote is preserved. + assert "Released 1.0.0 in March 2024" in text + + def test_idempotent_when_already_aligned(self, tmp_path: Path): + path = tmp_path / "CLAUDE.md" + already_aligned = DOCKER_CLAUDE.replace("**Version:** 1.0.0", "**Version:** 1.2.1") + path.write_text(already_aligned, encoding="utf-8") + + result = sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + assert result.action == "idempotent" + assert path.read_text(encoding="utf-8") == already_aligned + + def test_no_pattern_present_is_idempotent(self, tmp_path: Path): + path = tmp_path / "CLAUDE.md" + path.write_text(CLAUDE_NO_VERSION_AT_ALL, encoding="utf-8") + + result = sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + assert result.action == "idempotent" + assert path.read_text(encoding="utf-8") == CLAUDE_NO_VERSION_AT_ALL + + def test_missing_file_returns_missing(self, tmp_path: Path): + result = sync_claude( + tmp_path / "CLAUDE.md", + plugin_version="1.2.1", + previous_version="1.0.0", + ) + assert result.action == "missing" + + def test_word_boundary_protects_longer_version_strings(self, tmp_path: Path): + """``v1.0.0`` must not match inside ``v1.0.0-beta`` or ``v1.0.01``. + Defends the \\b anchor in the rewrite regex.""" + path = tmp_path / "CLAUDE.md" + content = ( + "# CLAUDE.md\n\n" + "**Version:** 1.0.0\n\n" + "Forward-references: v1.0.0-beta exists in the dev branch.\n" + ) + path.write_text(content, encoding="utf-8") + + sync_claude( + path, + plugin_version="1.2.1", + previous_version="1.0.0", + ) + + text = path.read_text(encoding="utf-8") + assert "**Version:** 1.2.1" in text + # v1.0.0-beta is left alone because \b stops the match before the dash. + assert "v1.0.0-beta" in text + + +# --------------------------------------------------------------------------- +# ROADMAP tests +# --------------------------------------------------------------------------- + + +class TestRoadmap: + def test_updates_steam_current_line(self, tmp_path: Path): + path = tmp_path / "ROADMAP.md" + path.write_text(STEAM_ROADMAP, encoding="utf-8") + + result = sync_roadmap(path, plugin_version="1.2.1") + + assert result.action == "updated" + text = path.read_text(encoding="utf-8") + assert "**Current:** v1.2.1" in text + assert "**Current:** v1.0.0" not in text + + def test_table_and_current_marker_untouched(self, tmp_path: Path): + path = tmp_path / "ROADMAP.md" + path.write_text(STEAM_ROADMAP, encoding="utf-8") + + sync_roadmap(path, plugin_version="1.2.1") + + text = path.read_text(encoding="utf-8") + # Table row is preserved verbatim, including the (current) marker + # on v1.0.0 -- the action does NOT move the marker. + assert "| v1.0.0 (current) | Stable | 30 |" in text + assert "| v0.1.0 | - | 14 |" in text + + def test_docker_style_no_current_label_is_idempotent(self, tmp_path: Path): + path = tmp_path / "ROADMAP.md" + path.write_text(DOCKER_ROADMAP, encoding="utf-8") + + result = sync_roadmap(path, plugin_version="1.2.1") + + assert result.action == "idempotent" + assert path.read_text(encoding="utf-8") == DOCKER_ROADMAP + + def test_idempotent_when_already_aligned(self, tmp_path: Path): + aligned = STEAM_ROADMAP.replace("**Current:** v1.0.0", "**Current:** v1.2.1") + path = tmp_path / "ROADMAP.md" + path.write_text(aligned, encoding="utf-8") + + result = sync_roadmap(path, plugin_version="1.2.1") + + assert result.action == "idempotent" + assert path.read_text(encoding="utf-8") == aligned + + def test_missing_file_returns_missing(self, tmp_path: Path): + result = sync_roadmap(tmp_path / "ROADMAP.md", plugin_version="1.2.1") + assert result.action == "missing" + + +# --------------------------------------------------------------------------- +# End-to-end: sync_repo +# --------------------------------------------------------------------------- + + +class TestSyncRepo: + def test_full_steam_run(self, tmp_path: Path): + (tmp_path / "CHANGELOG.md").write_text(STEAM_CHANGELOG, encoding="utf-8") + (tmp_path / "CLAUDE.md").write_text(STEAM_CLAUDE, encoding="utf-8") + (tmp_path / "ROADMAP.md").write_text(STEAM_ROADMAP, encoding="utf-8") + + result = sync_repo( + tmp_path, + plugin_version="1.2.1", + previous_version="1.0.0", + repository="TMHSDigital/Steam-Cursor-Plugin", + release_date="2026-04-25", + ) + + assert result.changed is True + assert result.changelog.action == "inserted" + assert result.claude.action == "updated" + assert result.roadmap.action == "updated" + assert len(result.files_changed) == 3 + + def test_full_docker_run(self, tmp_path: Path): + (tmp_path / "CHANGELOG.md").write_text(DOCKER_CHANGELOG, encoding="utf-8") + (tmp_path / "CLAUDE.md").write_text(DOCKER_CLAUDE, encoding="utf-8") + (tmp_path / "ROADMAP.md").write_text(DOCKER_ROADMAP, encoding="utf-8") + + result = sync_repo( + tmp_path, + plugin_version="1.2.1", + previous_version="1.0.0", + repository="TMHSDigital/Docker-Developer-Tools", + release_date="2026-04-25", + ) + + # Docker has no **Current:** label so ROADMAP is idempotent. + assert result.changelog.action == "inserted" + assert result.claude.action == "updated" + assert result.roadmap.action == "idempotent" + assert result.changed is True + + def test_second_run_is_pure_noop(self, tmp_path: Path): + """Idempotency end-to-end. Run sync_repo twice and assert that the + second invocation makes zero file edits.""" + (tmp_path / "CHANGELOG.md").write_text(STEAM_CHANGELOG, encoding="utf-8") + (tmp_path / "CLAUDE.md").write_text(STEAM_CLAUDE, encoding="utf-8") + (tmp_path / "ROADMAP.md").write_text(STEAM_ROADMAP, encoding="utf-8") + + sync_repo( + tmp_path, + plugin_version="1.2.1", + previous_version="1.0.0", + repository="TMHSDigital/Steam-Cursor-Plugin", + release_date="2026-04-25", + ) + + snapshot = { + p.name: p.read_text(encoding="utf-8") + for p in tmp_path.iterdir() + } + + second = sync_repo( + tmp_path, + plugin_version="1.2.1", + previous_version="1.0.0", + repository="TMHSDigital/Steam-Cursor-Plugin", + release_date="2026-04-25", + ) + + assert second.changed is False + assert second.changelog.action == "idempotent" + assert second.claude.action == "idempotent" + assert second.roadmap.action == "idempotent" + + for p in tmp_path.iterdir(): + assert p.read_text(encoding="utf-8") == snapshot[p.name], ( + f"second run mutated {p.name}" + ) + + def test_all_three_files_missing_does_not_crash(self, tmp_path: Path): + result = sync_repo( + tmp_path, + plugin_version="1.2.1", + previous_version="1.0.0", + repository="TMHSDigital/Empty", + release_date="2026-04-25", + ) + assert result.changed is False + assert result.changelog.action == "missing" + assert result.claude.action == "missing" + assert result.roadmap.action == "missing" + + +# --------------------------------------------------------------------------- +# CLI exit codes & output +# --------------------------------------------------------------------------- + + +class TestCliExitCodes: + def _run(self, repo: Path, *extra: str) -> int: + """Run the script as a subprocess so we exercise the real entry.""" + cmd = [ + sys.executable, + str(REPO_ROOT / "scripts" / "release_doc_sync" / "sync.py"), + "--repo-path", str(repo), + "--plugin-version", "1.2.1", + "--previous-version", "1.0.0", + "--repository", "TMHSDigital/Docker-Developer-Tools", + "--date", "2026-04-25", + *extra, + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + return proc.returncode + + def test_rc_0_when_nothing_changed(self, tmp_path: Path): + # No files at all => everything missing => no changes. + rc = self._run(tmp_path) + assert rc == 0 + + def test_rc_1_when_something_changed(self, tmp_path: Path): + (tmp_path / "CLAUDE.md").write_text(DOCKER_CLAUDE, encoding="utf-8") + rc = self._run(tmp_path) + assert rc == 1 + + def test_rc_0_on_second_run(self, tmp_path: Path): + (tmp_path / "CLAUDE.md").write_text(DOCKER_CLAUDE, encoding="utf-8") + first = self._run(tmp_path) + second = self._run(tmp_path) + assert first == 1 + assert second == 0 + + def test_rejects_garbage_plugin_version(self, tmp_path: Path): + with pytest.raises(SystemExit) as exc: + main([ + "--repo-path", str(tmp_path), + "--plugin-version", "v1.2.1", # 'v' prefix forbidden + "--previous-version", "1.0.0", + "--repository", "TMHSDigital/X", + ]) + assert "must be MAJOR.MINOR.PATCH" in str(exc.value) + + def test_rejects_bad_repository(self, tmp_path: Path): + with pytest.raises(SystemExit) as exc: + main([ + "--repo-path", str(tmp_path), + "--plugin-version", "1.2.1", + "--previous-version", "1.0.0", + "--repository", "no-slash", + ]) + assert "owner/name" in str(exc.value) + + def test_github_output_emitted(self, tmp_path: Path, monkeypatch): + (tmp_path / "CLAUDE.md").write_text(DOCKER_CLAUDE, encoding="utf-8") + out_file = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + + rc = main([ + "--repo-path", str(tmp_path), + "--plugin-version", "1.2.1", + "--previous-version", "1.0.0", + "--repository", "TMHSDigital/Docker-Developer-Tools", + "--date", "2026-04-25", + "--github-output", + ]) + + assert rc == 1 + body = out_file.read_text(encoding="utf-8") + assert "changed=true" in body + assert "files-changed=CLAUDE.md" in body + assert "claude-action=updated" in body + assert "changelog-action=missing" in body + assert "roadmap-action=missing" in body + + +# --------------------------------------------------------------------------- +# Composite action shape +# --------------------------------------------------------------------------- + + +ACTION_YML = REPO_ROOT / ".github" / "actions" / "release-doc-sync" / "action.yml" + + +@pytest.fixture(scope="module") +def action_doc(): + return yaml.safe_load(ACTION_YML.read_text(encoding="utf-8")) + + +class TestCompositeAction: + def test_action_yaml_parses(self, action_doc): + assert action_doc["name"] + assert action_doc["runs"]["using"] == "composite" + + def test_required_inputs_present(self, action_doc): + inputs = action_doc["inputs"] + for key in ( + "plugin-version", + "previous-version", + "repository", + "release-date", + "python-version", + "meta-repo-ref", + "caller-path", + ): + assert key in inputs, f"missing input: {key}" + + def test_required_inputs_marked_required(self, action_doc): + inputs = action_doc["inputs"] + assert inputs["plugin-version"].get("required") is True + assert inputs["previous-version"].get("required") is True + + def test_outputs_present(self, action_doc): + outputs = action_doc["outputs"] + for key in ( + "changed", + "files-changed", + "changelog-action", + "claude-action", + "roadmap-action", + ): + assert key in outputs, f"missing output: {key}" + + def test_meta_repo_ref_default_is_v1_0(self, action_doc): + """The pinning convention from DTD#5 is that tool repos consume + @v1.0 (matching drift-check@v1.7's pattern of major-floating tags). + Defending the default keeps tool-repo PRs from accidentally + consuming @main.""" + assert action_doc["inputs"]["meta-repo-ref"]["default"] == "v1.0" + + def test_steps_follow_drift_check_pattern(self, action_doc): + """Composite must check out the meta-repo at the pinned ref into a + side directory, set up Python, and invoke the sync script.""" + steps = action_doc["runs"]["steps"] + uses = [s.get("uses", "") for s in steps] + assert any("actions/checkout" in u for u in uses), "missing actions/checkout" + assert any("actions/setup-python" in u for u in uses), "missing setup-python" + + checkout_step = next(s for s in steps if "actions/checkout" in s.get("uses", "")) + assert checkout_step["with"]["repository"] == "TMHSDigital/Developer-Tools-Directory" + assert checkout_step["with"]["path"] == ".release-doc-sync" + + shell_runs = [s.get("run", "") for s in steps if "run" in s] + assert any("scripts/release_doc_sync/sync.py" in r for r in shell_runs), ( + "no step invokes the sync script" + ) + + def test_action_does_not_request_github_token(self, action_doc): + """The action runs inside the caller's release.yml and edits files + in-place; the caller's existing 'Commit and tag' step picks them + up. No git operations and no GitHub API calls happen here, so the + action must NOT ask for a token (would be confusing to consumers + and create an unnecessary scope-creep surface).""" + assert "github-token" not in action_doc["inputs"]