From 9c978bb3c58431000a12907fb7bf3b292601e258 Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Mon, 7 Sep 2026 19:30:21 +0200 Subject: [PATCH 1/2] feat(install): share skill sources across local agents Summary Install one shared Commit It source for Codex, Claude Code and Gemini. Changes - Add guarded installation, explicit updates and provider discovery links. - Document per-agent setup and release-time version selection. - Preserve the merged conflict-resolution policy and upstream version 2.9.0. Verification - 16 package tests passed; release metadata and shell syntax checks passed. - Codex discovery and invocation passed; Claude discovery passed. - Claude invocation blocked by expired OAuth; Gemini CLI unavailable. Refs: #3 --- .github/workflows/checks.yml | 6 +- CHANGELOG.md | 12 +++ README.md | 141 +++++++++++++++++++++++------- SKILL.md | 7 ++ docs/releasing.md | 27 ++++++ scripts/check_release.py | 32 +++++++ scripts/install.py | 163 +++++++++++++++++++++++++++++++++++ tests/test_install.py | 82 ++++++++++++++++++ tests/test_package.py | 4 +- tests/test_release.py | 25 ++++++ 10 files changed, 463 insertions(+), 36 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/releasing.md create mode 100644 scripts/check_release.py create mode 100644 scripts/install.py create mode 100644 tests/test_install.py create mode 100644 tests/test_release.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8bc3d24..e6f3324 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -4,7 +4,10 @@ permissions: contents: read jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -12,3 +15,4 @@ jobs: persist-credentials: false - run: python3 -m unittest discover -s tests - run: sh -n scripts/protect-branch.sh + - run: python3 scripts/check_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..48479df --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + +- Share one local source across Codex, Claude Code and Gemini discovery paths. +- Add a standalone installer, explicit updates and local-change preservation checks. +- Document per-agent installation prompts and release-time version selection. + +- Incorporate the verified conflict-resolution policy from PR #2 at `69a2f06190d04fc4dc212498490540eb4b443998`; the existing upstream version is retained; no additional release version is selected. + +Reconcile related assigned policy work before selecting the next release. +No release number has been selected by these installation changes. diff --git a/README.md b/README.md index ae71a9d..da91c6e 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,128 @@ # Commit It -A reusable agent skill for scoped GitHub delivery, explicit worker coordination, -and concise pull request reviews. - -- Connect requested work, issues, commits, verification and PRs. -- Preserve foreign changes and the repository's own authority rules. -- Track worker ownership independently of shared GitHub accounts. -- Publish precise `Self-review: LGTM`, `Review: LGTM` or actionable change requests. -- Use `Conflict resolution: LGTM` only for an explicitly assigned resolver and verified integration. -- Reuse valid evidence instead of repeating broad audits. +A reusable skill for scoped GitHub delivery in Codex, Claude Code and Gemini CLI. +Connect requested work, issues, commits, verification and PRs while preserving foreign +changes, actual worker ownership and the repository's authority rules. ## Install -Ask your agent's skill installer to install `sungyongcho/commit-it` from GitHub. -The skill is at the repository root. Prefer an exact reviewed commit for managed use. +The same `SKILL.md`, references and helpers work across supported local skill loaders. +The installer keeps one source at `~/Documents/skills/commit-it` and links agent discovery +paths to it. Codex and Gemini share `~/.agents/skills/commit-it`; Claude Code uses +`~/.claude/skills/commit-it`. It does not install agent applications or change authentication. + +You need Python 3 and Git. GitHub operations also require `gh` and the appropriate login. +Missing tools are reported. Use a reviewed source commit for managed installations. +A version label alone does not identify unpublished local edits. + +### Codex + +Copy this prompt into Codex: + +```text +Install Commit It from https://github.com/sungyongcho/commit-it for Codex. +Read the current README and inspect scripts/install.py from one reviewed source commit. +Use the default shared source location and run the installer with --agent codex. +Preserve existing skills and local changes; report conflicts instead of overwriting them. +Verify the resolved source, source commit and installed files, then confirm skill discovery. +Do not install other applications, change credentials, or perform GitHub writes. +``` + +Or run these commands after reviewing the downloaded source: + +```sh +install_source=$(mktemp -d) +git clone --depth 1 https://github.com/sungyongcho/commit-it "$install_source/commit-it" +git -C "$install_source/commit-it" rev-parse HEAD +python3 "$install_source/commit-it/scripts/install.py" --agent codex --dry-run +python3 "$install_source/commit-it/scripts/install.py" --agent codex +``` + +Invoke `$commit-it` with the requested scope. If discovery has not refreshed, restart +Codex. See [official local skill discovery](https://learn.chatgpt.com/docs/build-skills). + +### Claude Code + +Copy this prompt into Claude Code: + +```text +Install Commit It from https://github.com/sungyongcho/commit-it for Claude Code. +Read the current README and inspect scripts/install.py from one reviewed source commit. +Use the default shared source location and run the installer with --agent claude. +Preserve existing skills and local changes; report conflicts instead of overwriting them. +Verify the resolved source, source commit and installed files, then confirm /commit-it discovery. +Do not install other applications, change credentials, or perform GitHub writes. +``` + +```sh +install_source=$(mktemp -d) +git clone --depth 1 https://github.com/sungyongcho/commit-it "$install_source/commit-it" +git -C "$install_source/commit-it" rev-parse HEAD +python3 "$install_source/commit-it/scripts/install.py" --agent claude --dry-run +python3 "$install_source/commit-it/scripts/install.py" --agent claude +``` + +Invoke `/commit-it` with the requested scope. Restart Claude Code if its top-level +skills directory was created during the current session. See +[official Claude Code skills](https://code.claude.com/docs/en/skills). + +### Gemini CLI and shared installation + +Use `--agent gemini` for Gemini, `--agent both` for Codex and Claude Code, or +`--agent all` for all three. These choices share source files rather than duplicating them. +In Gemini, run `/skills reload` and `/skills list`, then explicitly ask to use the +`commit-it` skill. See [Gemini skills](https://geminicli.com/docs/cli/using-agent-skills/). -For a new, empty Codex skill location: +Linux, macOS and WSL are the supported installer targets. Native Windows and cloud +sessions are outside this local installer. File registration and runtime invocation +are separate checks; missing agent executables are reported, not counted as verified. + +### Updates and existing installations + +Re-running an identical installation is a no-op. To update an installer-managed copy, +review a fresh source checkout and run its installer with the same selection and +`--update`. Recorded hashes must still match the current installation. Extra, modified, +redirected or unmanaged files stop the update. `--dry-run` does not write files. + +If the shared source is your development Git checkout, link it using its own installer: ```sh -git clone https://github.com/sungyongcho/commit-it "$HOME/.codex/skills/commit-it" +python3 "$HOME/Documents/skills/commit-it/scripts/install.py" --agent all ``` -Claude Code can use the same package at `$HOME/.claude/skills/commit-it`. Preserve -existing installations and local instructions; do not clone over or reset them. -A managed installer should copy the package files unchanged and put project-specific -policy in the project's or host's own instructions. New Codex skill installations -are available on the next turn. +Update that checkout through its repository workflow. Do not replace it with a downloaded +snapshot. Existing paths such as `~/.codex/skills/commit-it` require reviewed migration. +Keep backups outside skill search locations and reconcile useful local changes before +replacing directories with links. Do not blindly reset or clone over an installation. ## Use -Invoke `$commit-it` with the requested scope, or let the agent select it for relevant -delivery work. It does not grant permission to publish, merge, change protections, -spend money or overwrite user work. Repository and user instructions take precedence. +Start with `SKILL.md`. Follow applicable repository instructions, including `AGENTS.md` +and `CLAUDE.md`, without replacing or consolidating them during installation. Review +the concrete commit preview before authorizing execution. Loading the skill does not +authorize publication, merge, protection changes, spending or overwriting work. + +Read [worker coordination](references/worker-coordination.md) for named workers and +[PR review](references/pr-review.md) for review work. Reload the real source when another +conversation may have updated it; existing context is not rewritten by filesystem changes. -Start with `SKILL.md`. Read `references/worker-coordination.md` when using named -workers, and `references/pr-review.md` for review work. Explicit resolver assignments -use the [conflict-resolution protocol](references/conflict-resolution.md) and its -[copyable prompt](references/conflict-resolution.md#copyable-prompt). The optional protection helper -requires Python 3 and an authenticated GitHub CLI, and changes protection only when -you explicitly authorize that operation. +For an explicitly assigned resolver of a named PR set, read +[conflict resolution](references/conflict-resolution.md). Ordinary worker authority is unchanged. ## Maintain -This repository is the canonical reusable package. Keep personal account routing, -private operations state, credentials and machine-specific settings outside it. -Consumers can vendor the six package files with an exact source commit and SHA-256 -manifest. That provides a complete offline copy without a recursive submodule checkout. -Update the source first, then review and advance the consumer's pin. +This repository is the canonical reusable package. Keep private routing, host policy, +credentials and machine-specific settings outside it. Use one local source with links +for authoring and reviewed commit pins for distributed recovery snapshots. -Run `python3 -m unittest discover -s tests` and `sh -n scripts/protect-branch.sh`. +Ordinary policy edits do not bump the release version. Track working changes by content +hash and Git state. Resolve pending related changes before selecting a release number. +See [release policy](docs/releasing.md) and [unreleased changes](CHANGELOG.md). + +```sh +python3 -m unittest discover -s tests +sh -n scripts/protect-branch.sh +python3 scripts/check_release.py +``` Created by [Sungyong Cho](https://sungyongcho.com). diff --git a/SKILL.md b/SKILL.md index c50844f..5a9ce32 100644 --- a/SKILL.md +++ b/SKILL.md @@ -9,6 +9,13 @@ metadata: Turn the current task's verified changes into the smallest coherent commit sequence. +When another conversation may have updated this skill, resolve the installed directory +and reread its current source and relevant references before use. Follow applicable +repository instructions, including AGENTS.md and CLAUDE.md, without replacing them. +For requested changes to the skill itself, update the canonical source through its +configured local maintenance workflow. Preserve concurrent edits and do not create an +independent provider copy. Ordinary policy edits do not select a new release version. + ## Boundaries - Read repository-local commit instructions first. They override this skill, including diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..507b359 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,27 @@ +# Release policy + +`SKILL.md` metadata is the single source for the released semantic version. Do not +bump it for each conversation or policy edit. Track unpublished work with actual +Git state and content hashes, and accumulate release notes under `Unreleased`. + +Before a release, inspect current source, relevant installed variants, assigned issues +and related PRs. Reconcile completed changes without taking over active work. Choose +the next version only after scope and verification are known: compatible fixes use +patch, additive behavior uses minor, and incompatible documented interfaces use major. +Follow [Semantic Versioning](https://semver.org/). + +Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +and repository templates. Ordinary changes describe shipped behavior; a dedicated +preparation commit may use `chore(release): prepare vX.Y.Z`. Commits do not automatically +create releases. Preserve required issue references and actual evidence. + +Run package and installer tests, shell syntax checks and `scripts/check_release.py`. +For an explicitly authorized release, move the selected notes from Unreleased to a +dated version section and run `scripts/check_release.py --tag vX.Y.Z` with its actual +tag name. This validates agreement without creating a tag. Never rewrite published +tags or infer publication authority from passing checks. + +Shared development source may contain unpublished work. Consumer snapshots should +record exact source commits and hashes. Recovery pins cannot overwrite newer live +source. Verify public installation links before publishing promotional material that +promises the commands are available. diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..9779bf4 --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Validate release metadata and an optional proposed tag without publishing.""" +import argparse +from pathlib import Path +import re + + +def check(root, tag=None): + skill = (root / 'SKILL.md').read_text() + matches = re.findall(r'^ version: "((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))"$', skill, re.M) + if len(matches) != 1: + raise ValueError('Expected one stable semantic version in skill metadata.') + version = matches[0] + changelog = (root / 'CHANGELOG.md').read_text() + if '## Unreleased\n' not in changelog: + raise ValueError('Missing Unreleased section.') + if tag is not None: + if tag != 'v' + version: + raise ValueError('Proposed tag does not match skill metadata.') + if not re.search(r'^## (?:\[)?' + re.escape(version) + r'(?:\])? - \d{4}-\d{2}-\d{2}$', changelog, re.M): + raise ValueError('Missing dated changelog section for the proposed release.') + return version + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--tag') + args = parser.parse_args() + try: + print('Release metadata valid: ' + check(Path(__file__).resolve().parents[1], args.tag)) + except ValueError as error: + parser.exit(1, str(error) + '\n') diff --git a/scripts/install.py b/scripts/install.py new file mode 100644 index 0000000..6351f28 --- /dev/null +++ b/scripts/install.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Install one Commit It source and link supported local agent discovery paths.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + +MARKER = '.commit-it-install.json' + + +class InstallError(Exception): + """An installation conflict that must preserve existing content.""" + + +def package(source): + required = ['SKILL.md', 'agents/openai.yaml', 'scripts/protect-branch.sh'] + required += [name for name in ('README.md', 'CHANGELOG.md') if (source / name).is_file()] + names = sorted(set(required + [p.relative_to(source).as_posix() + for area in ('references', 'scripts', 'docs') for p in (source / area).rglob('*') + if p.is_file() and '__pycache__' not in p.parts and p.suffix in ('.md', '.py', '.sh')])) + result = {} + for name in names: + p = source / name + if not p.is_file() or any(parent.is_symlink() for parent in (p, *p.parents) if parent != source and source in parent.parents): + raise InstallError('Missing or redirected package file: ' + name) + result[name] = p.read_bytes() + if not result['SKILL.md'].startswith(b'---\nname: commit-it\n'): + raise InstallError('Invalid Commit It package.') + return result + + +def hashes(data): + return {name: hashlib.sha256(value).hexdigest() for name, value in data.items()} + + +def _install(source, destination, agents, update=False, dry_run=False, home=None): + source, destination = Path(source).resolve(), Path(destination).absolute() + home = Path(home) if home is not None else Path.home() + data = package(source) + # Gemini also discovers .agents/skills; share that path rather than duplicating it. + locations = {'codex': '.agents/skills', 'claude': '.claude/skills', 'gemini': '.agents/skills'} + targets = sorted({home / locations[agent] / 'commit-it' for agent in agents}) + legacy = [home / '.codex/skills/commit-it', home / '.gemini/skills/commit-it'] + for path in legacy: + if (path.exists() or path.is_symlink()) and path not in targets: + raise InstallError('Existing legacy discovery path requires reconciliation: ' + str(path)) + for path in targets: + if (path.exists() or path.is_symlink()) and not (path.is_symlink() and path.resolve() == destination.resolve()): + raise InstallError('Preserve the existing installation: ' + str(path)) + same_source = source == destination.resolve() + exists = destination.exists() or destination.is_symlink() + if destination.is_symlink(): + raise InstallError('Choose the real canonical directory, not a destination symlink.') + old = None + if exists and not same_source: + marker = destination / MARKER + if not marker.is_file() or marker.is_symlink(): + raise InstallError('Existing source is not managed by this installer.') + old = json.loads(marker.read_text()) + actual = {p.relative_to(destination).as_posix(): p.read_bytes() for p in destination.rglob('*') + if p.is_file() and p.name != MARKER and '__pycache__' not in p.parts} + if any(p.is_symlink() for p in destination.rglob('*')) or hashes(actual) != old.get('files'): + raise InstallError('Local changes require review before update.') + if old['files'] != hashes(data) and not update: + raise InstallError('A different package requires --update.') + result = {'source': str(destination), 'agents': agents, 'targets': [str(p) for p in targets], + 'files': hashes(data), 'dry_run': dry_run} + if dry_run: + return result + destination.parent.mkdir(parents=True, exist_ok=True) + changed = not same_source and (not old or old['files'] != hashes(data)) + previous = None + created_links = [] + try: + if changed: + with tempfile.TemporaryDirectory(prefix='.commit-it-stage-', dir=destination.parent) as temporary: + stage = Path(temporary) / 'commit-it' + stage.mkdir() + for name, content in data.items(): + p = stage / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(content) + p.chmod(0o755 if (source / name).stat().st_mode & 0o111 else 0o644) + revision = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], capture_output=True, text=True) + (stage / MARKER).write_text(json.dumps({'schema': 1, 'repository': 'sungyongcho/commit-it', + 'revision': revision.stdout.strip() if revision.returncode == 0 else None, + 'files': hashes(data)}, indent=2) + '\n') + if exists: + current = {p.relative_to(destination).as_posix(): p.read_bytes() for p in destination.rglob('*') + if p.is_file() and p.name != MARKER and '__pycache__' not in p.parts} + if any(p.is_symlink() for p in destination.rglob('*')) or hashes(current) != old['files']: + raise InstallError('Installation changed concurrently; preserve local work.') + previous = Path(temporary) / 'previous' + os.rename(destination, previous) + try: + os.rename(stage, destination) + for path in targets: + if not path.is_symlink(): + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to(destination, target_is_directory=True) + created_links.append(path) + except BaseException: + for path in reversed(created_links): + path.unlink() + if destination.exists(): + shutil.rmtree(destination) + if previous is not None: + os.rename(previous, destination) + raise + else: + for path in targets: + if not path.is_symlink(): + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to(destination, target_is_directory=True) + created_links.append(path) + except BaseException: + for path in reversed(created_links): + if path.is_symlink(): + path.unlink() + raise + result['reload'] = {'codex': 'Reload discovery or restart if needed.', + 'claude': 'Restart if the skills directory was newly created.', + 'gemini': 'Run /skills reload, then /skills list.'} + result['tools'] = {name: shutil.which(name) is not None for name in ('git', 'python3', 'gh', *agents)} + return result + + +def install(source, destination, agents, update=False, dry_run=False, home=None): + if dry_run: + return _install(source, destination, agents, update, True, home) + user_home = Path(home) if home is not None else Path.home() + state = user_home / '.local/state/commit-it-installer' + state.mkdir(parents=True, exist_ok=True, mode=0o700) + with (state / 'install.lock').open('a') as stream: + try: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise InstallError('Another installation is in progress.') from error + return _install(source, destination, agents, update, False, home) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--agent', choices=('codex', 'claude', 'gemini', 'both', 'all'), required=True) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument('--destination', type=Path, default=Path.home() / 'Documents/skills/commit-it') + parser.add_argument('--update', action='store_true') + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + agents = ['codex', 'claude', 'gemini'] if args.agent == 'all' else ['codex', 'claude'] if args.agent == 'both' else [args.agent] + try: + print(json.dumps(install(args.source, args.destination, agents, args.update, args.dry_run), indent=2)) + except (InstallError, OSError, ValueError) as error: + parser.exit(1, str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..b721bc5 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,82 @@ +"""Check shared installation and updates without writing to actual agent directories.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('installer', Path(__file__).resolve().parents[1] / 'scripts/install.py') +installer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(installer) + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) / 'home with spaces' + self.source = Path(self.temp.name) / 'source' + for name, content in {'SKILL.md': '---\nname: commit-it\ndescription: Fixture\n---\nHello\n', + 'agents/openai.yaml': 'interface: {}\n', 'scripts/protect-branch.sh': '#!/bin/sh\n', + 'references/test.md': 'Reference\n'}.items(): + p = self.source / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + self.destination = self.home / 'Documents/skills/commit-it' + + def run_install(self, **kwargs): + return installer.install(self.source, self.destination, ['codex', 'claude', 'gemini'], home=self.home, **kwargs) + + def test_all_agents_share_one_complete_source(self): + result = self.run_install() + self.assertEqual(len(result['targets']), 2) + self.assertTrue((self.destination / 'references/test.md').is_file()) + for path in result['targets']: + self.assertEqual(Path(path).resolve(), self.destination) + self.assertEqual(self.run_install()['files'], result['files']) + + def test_dry_run_does_not_create_files(self): + self.run_install(dry_run=True) + self.assertFalse(self.home.exists()) + + def test_update_is_explicit_and_refuses_local_changes(self): + self.run_install() + (self.source / 'references/test.md').write_text('New reference\n') + with self.assertRaisesRegex(installer.InstallError, '--update'): + self.run_install() + self.run_install(update=True) + (self.destination / 'references/test.md').write_text('My local work\n') + with self.assertRaisesRegex(installer.InstallError, 'Local changes'): + self.run_install(update=True) + self.assertEqual((self.destination / 'references/test.md').read_text(), 'My local work\n') + + def test_existing_legacy_copy_is_preserved(self): + legacy = self.home / '.codex/skills/commit-it' + legacy.mkdir(parents=True) + (legacy / 'mine').write_text('Keep\n') + with self.assertRaisesRegex(installer.InstallError, 'legacy'): + self.run_install() + self.assertTrue((legacy / 'mine').is_file()) + + def test_failure_restores_previous_source(self): + self.run_install() + old = (self.destination / 'SKILL.md').read_bytes() + (self.source / 'SKILL.md').write_text('---\nname: commit-it\n---\nNew\n') + original = installer.os.rename + def fail(source, target): + if Path(source).name == 'commit-it' and '.commit-it-stage-' in str(source): + raise OSError('Fixture failure') + return original(source, target) + with patch.object(installer.os, 'rename', fail), self.assertRaises(OSError): + self.run_install(update=True) + self.assertEqual((self.destination / 'SKILL.md').read_bytes(), old) + + def test_local_checkout_can_be_linked_without_copying_itself(self): + self.destination = self.source + self.run_install() + self.assertFalse((self.source / installer.MARKER).exists()) + self.assertTrue((self.home / '.claude/skills/commit-it').is_symlink()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_package.py b/tests/test_package.py index 91f8f45..c6ade71 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -15,8 +15,8 @@ def test_skill_has_metadata_and_local_references(self): text = (ROOT / 'SKILL.md').read_text() self.assertTrue(text.startswith('---\nname: commit-it\n')) self.assertIn('description:', text.split('---', 2)[1]) - self.assertIn('version: "2.9.0"', text) - for file in [ROOT / 'SKILL.md', ROOT / 'README.md', *ROOT.joinpath('references').glob('*.md')]: + self.assertRegex(text, r'version: "\d+\.\d+\.\d+"') + for file in [ROOT / 'SKILL.md', ROOT / 'README.md', *ROOT.joinpath('docs').glob('*.md'), *ROOT.joinpath('references').glob('*.md')]: for link in re.findall(r'\]\(([^)]+)\)', file.read_text()): if '://' not in link and not link.startswith('#'): self.assertTrue((file.parent / link.split('#')[0]).exists(), link) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..79a85e1 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,25 @@ +"""Reject release labels that lack matching metadata and dated notes.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('release_check', Path(__file__).resolve().parents[1] / 'scripts/check_release.py') +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class ReleaseTests(unittest.TestCase): + def test_proposed_tag_requires_matching_version_and_dated_notes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'SKILL.md').write_text('---\nmetadata:\n version: "4.2.1"\n---\n') + (root / 'CHANGELOG.md').write_text('# Changelog\n\n## Unreleased\n') + self.assertEqual(release.check(root), '4.2.1') + with self.assertRaisesRegex(ValueError, 'does not match'): + release.check(root, 'v4.2.2') + with self.assertRaisesRegex(ValueError, 'Missing dated'): + release.check(root, 'v4.2.1') + with (root / 'CHANGELOG.md').open('a') as stream: + stream.write('\n## 4.2.1 - 2026-09-07\n\n- Verified change.\n') + self.assertEqual(release.check(root, 'v4.2.1'), '4.2.1') From 880710098336ab2e8bfdc82d06126474ab89fefe Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Mon, 7 Sep 2026 19:40:56 +0200 Subject: [PATCH 2/2] test(install): compare shared files across platform path aliases Summary Verify physical source identity without assuming macOS temporary paths are canonical. Changes - Compare linked installation targets using samefile instead of raw path spelling. Verification - 16 local package tests passed; diff check passed. - The original macOS CI failure showed /var resolving to /private/var. - Cross-platform CI will verify this correction on the pushed commit. Refs: #3 --- tests/test_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_install.py b/tests/test_install.py index b721bc5..d5d4ac7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -32,7 +32,7 @@ def test_all_agents_share_one_complete_source(self): self.assertEqual(len(result['targets']), 2) self.assertTrue((self.destination / 'references/test.md').is_file()) for path in result['targets']: - self.assertEqual(Path(path).resolve(), self.destination) + self.assertTrue(Path(path).samefile(self.destination)) self.assertEqual(self.run_install()['files'], result['files']) def test_dry_run_does_not_create_files(self):