diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index c71911d..8a61a2f 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -1,7 +1,7 @@ { "id": "sentinel", "name": "SENTINEL", - "version": "0.1.3", + "version": "1.0.0", "description": "AI-powered contextual security auditing. Red-team-grade SAST: taint flow, attack graphs, exploit chains, hardening.", "author": "Wembie", "repo": "https://github.com/Wembie/Sentinel", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1d1b467..ab22823 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sentinel", - "version": "0.1.3", + "version": "1.0.0", "description": "AI-powered contextual security auditing platform — red-team-grade SAST that thinks like an attacker.", "author": "Wembie", "license": "MIT", diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 4b391cb..ee3f2f8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -47,13 +47,7 @@ jobs: uses: astral-sh/setup-uv@v4 - name: Ensure static manifests are version-synced - shell: bash - run: | - python scripts/sync_version.py - if [ -n "$(git status --porcelain)" ]; then - echo "::error::Static manifests are out of sync with VERSION. Run: python scripts/sync_version.py and commit the updates." - exit 1 - fi + run: python scripts/release/sync_version.py --check - name: Build package run: uv build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51f2011..4ea789e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: with: python-version: "3.11" + - name: Ensure static manifests are version-synced + run: python scripts/release/sync_version.py --check + - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/gemini-extension.json b/gemini-extension.json index 87b46ab..4d89fee 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "sentinel", - "version": "0.1.3", + "version": "1.0.0", "description": "AI-powered contextual security auditing platform. Performs offensive-minded SAST: taint flow tracing, attack graph generation, exploit chain narratives, and hardening checklists.", "author": "Wembie", "repo": "https://github.com/Wembie/Sentinel", diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000..50d15f2 --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,26 @@ +# Release Scripts + +These scripts keep `VERSION` and the static release manifests in sync. + +## Commands + +```bash +python scripts/release/bump_version.py {major}.{minor}.{patch} +python scripts/release/bump_version.py 1.0.0 +python scripts/release/bump_version.py patch +python scripts/release/bump_version.py minor --commit +python scripts/release/sync_version.py +python scripts/release/sync_version.py --check +``` + +## What Each Script Does + +- `bump_version.py`: updates `VERSION`, then syncs all static manifests. +- `sync_version.py`: syncs the current `VERSION` value into the static manifests. + +## Suggested Flow + +1. Bump the version with `python scripts/release/bump_version.py `. +2. Review the changes. +3. Commit and push. +4. Let CI/CD validate with `python scripts/release/sync_version.py --check`. diff --git a/scripts/release/bump_version.py b/scripts/release/bump_version.py new file mode 100644 index 0000000..42c00b9 --- /dev/null +++ b/scripts/release/bump_version.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Bump VERSION and sync static manifests in one command. + +Examples: + python scripts/release/bump_version.py 1.0.1 + python scripts/release/bump_version.py patch + python scripts/release/bump_version.py minor --commit + python scripts/release/bump_version.py 2.0.0 --dry-run +""" +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + +import sync_version + +ROOT = sync_version.ROOT +VERSION_FILE = ROOT / "VERSION" +RELEASE_RE = re.compile(r"^(?P\d+)\.(?P\d+)\.(?P\d+)(?:[-+].+)?$") +BUMP_KINDS = {"major", "minor", "patch"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Update VERSION and sync all static manifests." + ) + parser.add_argument( + "target", + help="Exact version (e.g. 1.0.1) or bump kind: major, minor, patch.", + ) + parser.add_argument( + "--commit", + action="store_true", + help="Create a git commit with only the version-sync files.", + ) + parser.add_argument( + "--message", + help="Custom commit message. Only used with --commit.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the changes that would be made without writing files.", + ) + return parser.parse_args() + + +def resolve_target_version(current_version: str, target: str) -> str: + normalized = target.strip().lower() + if normalized in BUMP_KINDS: + match = RELEASE_RE.fullmatch(current_version) + if not match: + raise ValueError( + "Automatic major/minor/patch bumps require VERSION to be in X.Y.Z format." + ) + major = int(match.group("major")) + minor = int(match.group("minor")) + patch = int(match.group("patch")) + if normalized == "major": + return f"{major + 1}.0.0" + if normalized == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + if not sync_version.is_valid_semver(target): + raise ValueError( + f"Invalid version {target!r}. Use semver like 1.0.1 or a bump kind." + ) + return target + + +def _write_version(current_version: str, target_version: str, *, dry_run: bool = False) -> bool: + if current_version == target_version: + print(f" OK VERSION: already {target_version!r}") + return False + if dry_run: + print(f" DRY VERSION: {current_version!r} -> {target_version!r}") + return True + VERSION_FILE.write_text(f"{target_version}\n", encoding="utf-8") + print(f" BUMP VERSION: {current_version!r} -> {target_version!r}") + return True + + +def bump_version(target: str, *, dry_run: bool = False) -> tuple[str, bool, int]: + current_version = sync_version.read_version(VERSION_FILE) + target_version = resolve_target_version(current_version, target) + + print(f"Bumping project version toward {target_version!r}...\n") + version_changed = _write_version(current_version, target_version, dry_run=dry_run) + manifest_changes = sync_version.sync_static_manifests(target_version, dry_run=dry_run) + return target_version, version_changed, manifest_changes + + +def maybe_commit(target_version: str, message: str | None = None) -> bool: + paths = [VERSION_FILE, *sync_version.JSON_TARGETS, *sync_version.YAML_TARGETS] + rel_paths = [str(path.relative_to(ROOT)) for path in paths] + + subprocess.run(["git", "add", "--", *rel_paths], cwd=ROOT, check=True) + + diff_result = subprocess.run( + ["git", "diff", "--cached", "--quiet", "--", *rel_paths], + cwd=ROOT, + check=False, + ) + if diff_result.returncode == 0: + print("No version-sync changes to commit.") + return False + if diff_result.returncode != 1: + raise subprocess.CalledProcessError(diff_result.returncode, diff_result.args) + + commit_message = message or f"chore: bump version to {target_version}" + subprocess.run( + ["git", "commit", "-m", commit_message, "--", *rel_paths], + cwd=ROOT, + check=True, + ) + print(f"Created commit: {commit_message}") + return True + + +def main() -> None: + args = parse_args() + if args.dry_run and args.commit: + raise SystemExit("ERROR: --commit cannot be used with --dry-run") + if args.message and not args.commit: + raise SystemExit("ERROR: --message requires --commit") + + target_version, version_changed, manifest_changes = bump_version( + args.target, dry_run=args.dry_run + ) + + total_changed = int(version_changed) + manifest_changes + print() + if args.dry_run: + print(f"Dry-run complete. {total_changed} file(s) would be updated.") + return + + print(f"Done. {total_changed} file(s) updated.") + if args.commit: + maybe_commit(target_version, args.message) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_version.py b/scripts/release/sync_version.py similarity index 54% rename from scripts/sync_version.py rename to scripts/release/sync_version.py index 0e9c462..c4bd32f 100644 --- a/scripts/sync_version.py +++ b/scripts/release/sync_version.py @@ -2,10 +2,13 @@ """Sync the VERSION file to all static manifests. Run before tagging a release: - python scripts/sync_version.py + python scripts/release/sync_version.py Dry-run (print diffs, write nothing): - python scripts/sync_version.py --dry-run + python scripts/release/sync_version.py --dry-run + +Check mode (exit non-zero if files need updates): + python scripts/release/sync_version.py --check """ from __future__ import annotations @@ -14,8 +17,8 @@ import sys from pathlib import Path -ROOT = Path(__file__).parent.parent -DRY_RUN = "--dry-run" in sys.argv +ROOT = Path(__file__).resolve().parents[2] +SEMVER_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+].+)?") # JSON files where {"version": ""} must be updated. JSON_TARGETS: list[Path] = [ @@ -30,17 +33,21 @@ ] -def _read_version() -> str: - vf = ROOT / "VERSION" +def is_valid_semver(version: str) -> bool: + return bool(SEMVER_RE.fullmatch(version)) + + +def read_version(vf: Path | None = None) -> str: + vf = vf or (ROOT / "VERSION") if not vf.exists(): sys.exit(f"ERROR: VERSION file not found at {vf}") v = vf.read_text(encoding="utf-8").strip() - if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+].+)?", v): + if not is_valid_semver(v): sys.exit(f"ERROR: VERSION contains invalid semver: {v!r}") return v -def _sync_json(path: Path, version: str) -> bool: +def _sync_json(path: Path, version: str, *, dry_run: bool = False, check_only: bool = False) -> bool: if not path.exists(): print(f" SKIP (missing): {path.relative_to(ROOT)}") return False @@ -52,15 +59,16 @@ def _sync_json(path: Path, version: str) -> bool: old = data.get("version", "") data["version"] = version new_raw = json.dumps(data, indent=2, ensure_ascii=False) + "\n" - if DRY_RUN: - print(f" DRY {path.relative_to(ROOT)}: {old!r} -> {version!r}") + if dry_run or check_only: + prefix = "DRY " if dry_run else "NEED" + print(f" {prefix} {path.relative_to(ROOT)}: {old!r} -> {version!r}") return True path.write_text(new_raw, encoding="utf-8") print(f" BUMP {path.relative_to(ROOT)}: {old!r} -> {version!r}") return True -def _sync_yaml(path: Path, version: str) -> bool: +def _sync_yaml(path: Path, version: str, *, dry_run: bool = False, check_only: bool = False) -> bool: if not path.exists(): print(f" SKIP (missing): {path.relative_to(ROOT)}") return False @@ -75,28 +83,55 @@ def _sync_yaml(path: Path, version: str) -> bool: print(f" OK (current): {path.relative_to(ROOT)}") return False new_raw = pattern.sub(f'version: "{version}"', raw, count=1) - if DRY_RUN: - print(f" DRY {path.relative_to(ROOT)}: {old!r} -> {version!r}") + if dry_run or check_only: + prefix = "DRY " if dry_run else "NEED" + print(f" {prefix} {path.relative_to(ROOT)}: {old!r} -> {version!r}") return True path.write_text(new_raw, encoding="utf-8") print(f" BUMP {path.relative_to(ROOT)}: {old!r} -> {version!r}") return True -def main() -> None: - version = _read_version() +def sync_static_manifests( + version: str | None = None, *, dry_run: bool = False, check_only: bool = False +) -> int: + if dry_run and check_only: + raise ValueError("use only one of dry_run or check_only") + + version = version or read_version() + if not is_valid_semver(version): + raise ValueError(f"invalid semver: {version!r}") + print(f"Syncing version {version!r} to static manifests...\n") changed = 0 for p in JSON_TARGETS: - if _sync_json(p, version): + if _sync_json(p, version, dry_run=dry_run, check_only=check_only): changed += 1 for p in YAML_TARGETS: - if _sync_yaml(p, version): + if _sync_yaml(p, version, dry_run=dry_run, check_only=check_only): changed += 1 + return changed + + +def main() -> None: + dry_run = "--dry-run" in sys.argv + check_only = "--check" in sys.argv + + if dry_run and check_only: + sys.exit("ERROR: use only one of --dry-run or --check") + + changed = sync_static_manifests(dry_run=dry_run, check_only=check_only) print() - if DRY_RUN: + if check_only: + if changed: + sys.exit( + "ERROR: Static manifests are out of sync with VERSION. " + "Run: python scripts/release/sync_version.py and commit the updates." + ) + print("Check complete. All static manifests are in sync.") + elif dry_run: print(f"Dry-run complete. {changed} file(s) would be updated.") else: print(f"Done. {changed} file(s) updated.") diff --git a/sentinel.skill b/sentinel.skill index b2a0cde..cd3738b 100644 --- a/sentinel.skill +++ b/sentinel.skill @@ -1,6 +1,6 @@ --- name: sentinel -version: "0.1.3" +version: "1.0.0" description: AI-powered contextual security auditing — red-team-grade SAST, taint flow, attack graphs, exploit chains author: Wembie repo: https://github.com/Wembie/Sentinel diff --git a/tests/test_version_scripts.py b/tests/test_version_scripts.py new file mode 100644 index 0000000..e652d1e --- /dev/null +++ b/tests/test_version_scripts.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts" / "release" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import bump_version +import sync_version + + +def _make_version_files(root: Path, version: str) -> tuple[list[Path], list[Path]]: + version_file = root / "VERSION" + version_file.write_text(f"{version}\n", encoding="utf-8") + + json_targets = [ + root / ".claude-plugin" / "plugin.json", + root / ".agents" / "plugins" / "marketplace.json", + root / "gemini-extension.json", + ] + yaml_targets = [root / "sentinel.skill"] + + json_payloads = [ + '{\n "name": "sentinel",\n "version": "0.1.0"\n}\n', + '{\n "id": "sentinel",\n "version": "0.1.0"\n}\n', + '{\n "name": "sentinel",\n "version": "0.1.0"\n}\n', + ] + yaml_payload = '---\nname: sentinel\nversion: "0.1.0"\n---\n' + + for path, payload in zip(json_targets, json_payloads, strict=True): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(payload, encoding="utf-8") + yaml_targets[0].write_text(yaml_payload, encoding="utf-8") + + return json_targets, yaml_targets + + +def _point_scripts_at_tmp_root(monkeypatch: pytest.MonkeyPatch, root: Path) -> None: + json_targets, yaml_targets = _make_version_files(root, "1.0.0") + monkeypatch.setattr(sync_version, "ROOT", root) + monkeypatch.setattr(sync_version, "JSON_TARGETS", json_targets) + monkeypatch.setattr(sync_version, "YAML_TARGETS", yaml_targets) + monkeypatch.setattr(bump_version, "ROOT", root) + monkeypatch.setattr(bump_version, "VERSION_FILE", root / "VERSION") + + +def test_resolve_target_version_supports_semver_and_bump_kinds() -> None: + assert bump_version.resolve_target_version("1.2.3", "1.2.4") == "1.2.4" + assert bump_version.resolve_target_version("1.2.3", "patch") == "1.2.4" + assert bump_version.resolve_target_version("1.2.3", "minor") == "1.3.0" + assert bump_version.resolve_target_version("1.2.3", "major") == "2.0.0" + + +def test_bump_version_updates_version_and_manifests( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _point_scripts_at_tmp_root(monkeypatch, tmp_path) + + target_version, version_changed, manifest_changes = bump_version.bump_version("1.0.1") + + assert target_version == "1.0.1" + assert version_changed is True + assert manifest_changes == 4 + assert (tmp_path / "VERSION").read_text(encoding="utf-8").strip() == "1.0.1" + assert '"version": "1.0.1"' in (tmp_path / ".claude-plugin" / "plugin.json").read_text( + encoding="utf-8" + ) + assert 'version: "1.0.1"' in (tmp_path / "sentinel.skill").read_text(encoding="utf-8") + + +def test_bump_version_dry_run_does_not_write_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _point_scripts_at_tmp_root(monkeypatch, tmp_path) + + target_version, version_changed, manifest_changes = bump_version.bump_version( + "patch", dry_run=True + ) + + assert target_version == "1.0.1" + assert version_changed is True + assert manifest_changes == 4 + assert (tmp_path / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0" + assert '"version": "0.1.0"' in (tmp_path / "gemini-extension.json").read_text( + encoding="utf-8" + )