diff --git a/.codex/skills/git-commit/SKILL.md b/.codex/skills/git-commit/SKILL.md index dbf46e5..4fd7d9b 100644 --- a/.codex/skills/git-commit/SKILL.md +++ b/.codex/skills/git-commit/SKILL.md @@ -7,14 +7,15 @@ description: Review local git changes, write an appropriate commit message, crea ## Overview -Use this skill for repository submission work. Inspect changes first, derive a commit message from the actual diff, commit submodules before the parent repository, then push the current branch to every remote. +Use this skill for repository submission work. Inspect changes first, derive a commit message from the actual diff, ensure submodule commits are already pushed to the submodule remotes, commit submodules before the parent repository when needed, then push the current branch to every remote. ## Workflow 1. Read repository state before changing anything. 2. Detect submodules and handle dirty submodules first. -3. Commit the parent repository only after submodule commits are finished and the parent pointer is updated. -4. Push the current branch to every remote after local commits succeed. +3. If the parent repository contains a changed submodule pointer, verify that the referenced submodule commit is already pushed to the submodule remotes before committing the parent repository. +4. Commit the parent repository only after submodule commits are finished and the parent pointer is updated. +5. Push the current branch to every remote after local commits succeed. ## Inspect Repository State @@ -41,9 +42,11 @@ If the worktree is clean, report that there is nothing to commit. Do not create Treat submodules as independent repositories. - If `git status --short` in the parent repo shows a changed submodule entry, inspect whether the submodule itself has uncommitted work. +- If the parent repository shows a changed submodule pointer, enter the submodule even when its worktree is clean. - For each dirty submodule, enter the submodule and run the same inspection flow there first. - Write a commit message for the submodule based on its own diff, not the parent repository diff. - Commit and push the submodule before committing the parent repository. +- If the submodule worktree is clean but the parent pointer changed, verify that the submodule `HEAD` commit exists on the submodule remotes and push it if needed before committing the parent repository. - Return to the parent repository and verify that only the submodule pointer changed as expected. Use commands like: @@ -53,6 +56,8 @@ git -C status --short git -C diff --stat git -C branch --show-current git -C remote -v +git -C rev-parse HEAD +git -C ls-remote --heads git -C add -A git -C commit -m "" git -C push @@ -60,6 +65,14 @@ git -C push If a submodule has multiple remotes, push its current branch to every remote exactly like the parent repository. +Before the parent repository commit, explicitly validate the submodule remote state: + +- Read the submodule `HEAD` commit with `git -C rev-parse HEAD`. +- Read the submodule current branch with `git -C branch --show-current`. +- Compare against each submodule remote branch with `git -C rev-list /..HEAD`. +- If the branch has outgoing commits, push that submodule branch to every remote before touching the parent repository commit. +- If the submodule is in detached `HEAD`, stop and report it unless the user explicitly asks to push a detached commit reference. + ## Write Commit Messages Base the message on the diff, not on filenames alone. @@ -112,7 +125,7 @@ If one remote succeeds and another fails, report the partial result clearly and ## Failure Handling - If authentication or network access fails on a remote, keep the successful pushes and report which remotes still need retry. -- If a submodule push fails, do not commit the parent repository submodule pointer unless the user explicitly asks to proceed with that inconsistent state. +- If a submodule push fails, or if the referenced submodule commit is not confirmed on the submodule remotes, do not commit the parent repository submodule pointer unless the user explicitly asks to proceed with that inconsistent state. - If the parent repository has no changes after submodule processing, report that explicitly. - If a command hangs during remote access, retry with a non-interactive or bounded-timeout variant to surface a concrete error. @@ -120,6 +133,7 @@ If one remote succeeds and another fails, report the partial result clearly and - Submodule worktrees inspected - Dirty submodules committed and pushed first +- Changed submodule pointers validated against submodule remotes - Parent repository diff re-checked after submodule updates - Parent repository committed with a diff-based message - Current branch pushed to every remote diff --git a/.codex/skills/release-version/SKILL.md b/.codex/skills/release-version/SKILL.md new file mode 100644 index 0000000..6569aa8 --- /dev/null +++ b/.codex/skills/release-version/SKILL.md @@ -0,0 +1,159 @@ +--- +name: release-version +description: Create a new repository release using semantic version tags in the form `vx.y.z`, generate Chinese and English upgrade notes by comparing the new tag against the previous release tag, update `docs/zh/changelog.md` and `docs/en/changelog.md`, commit and push the `docs` submodule, then commit the parent repo's submodule pointer and push the new tag. Use when Codex needs to perform release preparation, changelog drafting, Git tag analysis, submodule release updates, or end-to-end version publishing. +--- + +# Release Version + +## Overview + +Create releases with a strict `vx.y.z` tag, produce bilingual changelog entries from actual Git history, and publish both the `docs` submodule update and the repository tag in one controlled workflow. + +Run the workflow from the repository root. Read [references/changelog-style.md](references/changelog-style.md) before drafting the human-facing update notes. + +## Workflow + +1. Validate the requested version. +2. Inspect the repository and determine the comparison range. +3. Draft bilingual changelog entries from the actual diff. +4. Commit and push the `docs` submodule. +5. Commit the parent repository update if the submodule pointer changed. +6. Create and push the annotated tag. + +Do not skip the repository inspection step. Release notes must come from the real diff between tags, not from guesswork. + +## Validate The Version + +- Accept only tags that match `^v\d+\.\d+\.\d+$`. +- Reject date-style tags such as `v20260414`. +- Confirm the target tag does not already exist locally or on any configured remote. +- Prefer the latest reachable semver tag as the previous release tag. +- If no earlier semver tag exists, fall back to the latest reachable tag of any format and state that fallback in the changelog drafting notes. + +Use the helper script first: + +```bash +python3 ~/.codex/skills/release-version/scripts/collect_release_context.py \ + --repo . \ + --tag v1.2.3 +``` + +If the caller already specifies the previous tag, pass it explicitly: + +```bash +python3 ~/.codex/skills/release-version/scripts/collect_release_context.py \ + --repo . \ + --tag v1.2.3 \ + --previous-tag v1.2.2 +``` + +## Inspect The Repository + +- Check `git status --short` in the parent repo. +- Check `git -C docs status --short` in the `docs` submodule. +- Read the JSON output of `collect_release_context.py`. +- Use the commit list, changed files, and insertions/deletions to decide what is user-visible. +- Prioritize behavior changes, new features, fixes, migrations, API changes, configuration changes, and documentation changes that matter to adopters. +- Ignore pure formatting churn unless it changes usage. + +If the working tree contains unrelated changes that would be risky to include in the release, stop and ask the user before proceeding. + +## Draft The Changelog + +Update these files: + +- `docs/zh/changelog.md` +- `docs/en/changelog.md` + +Prepend a new entry using this exact structure: + +```md +## ${tag} (${yyyy-MM-dd}) + +### 更新内容 + +${content} + +### 发布地址 + +- Github: +- Gitee: +``` + +For the English file, keep the same links and heading level, but translate the section heading and content naturally: + +```md +## ${tag} (${yyyy-MM-dd}) + +### Updates + +${content} + +### Release Links + +- Github: +- Gitee: +``` + +Changelog writing rules: + +- Write concise, user-facing summaries instead of raw commit subjects. +- Keep Chinese and English entries semantically aligned. +- Prefer 3-6 bullets unless the release is extremely small. +- Group related changes into a single bullet when that reads better. +- Mention compatibility-sensitive changes explicitly. +- If the comparison baseline is a non-semver fallback tag, note that in your private reasoning, not in the public changelog unless the user asks for it. + +## Commit And Push The Docs Submodule + +After editing the changelog files: + +1. Run `git -C docs status --short`. +2. Review the diff with `git -C docs diff -- docs/zh/changelog.md docs/en/changelog.md` or the actual file paths present in the submodule. +3. Commit inside the `docs` submodule with a focused message such as `docs: update changelog for v1.2.3`. +4. Push the `docs` submodule commit to its remote branch. + +Branch rule: + +- If `docs` is on a local branch, push that branch. +- If `docs` is detached, push `HEAD` to `origin/main` unless the repository clearly uses another default branch. + +## Commit The Parent Repository + +If the `docs` submodule pointer changed in the parent repository, commit it before tagging. Otherwise the release tag will not reference the new changelog revision. + +Recommended flow: + +```bash +git status --short +git add docs +git commit -m "chore: update docs submodule for v1.2.3" +``` + +Only include unrelated parent-repo changes if the user explicitly wants them in the release commit. + +## Create And Push The Tag + +Create an annotated tag after the repository state is ready: + +```bash +git tag -a v1.2.3 -m "Release v1.2.3" +``` + +Push the commit branch first if needed, then push the tag to every configured remote that should publish releases: + +```bash +git push github HEAD +git push origin HEAD +git push github v1.2.3 +git push origin v1.2.3 +``` + +Adjust the branch name if `HEAD` is not tracking the intended release branch. + +## Final Verification + +- Confirm `git rev-parse v1.2.3^{tag}` succeeds. +- Confirm `git ls-remote --tags github v1.2.3` and `git ls-remote --tags origin v1.2.3` show the new tag. +- Confirm the `docs` submodule remote contains the changelog commit. +- Summarize the previous tag used for comparison, the files updated, the commit hashes created, and the remotes pushed. diff --git a/.codex/skills/release-version/agents/openai.yaml b/.codex/skills/release-version/agents/openai.yaml new file mode 100644 index 0000000..7732fdf --- /dev/null +++ b/.codex/skills/release-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Version" + short_description: "Create semver tags and changelog drafts" + default_prompt: "Use $release-version to create a new vx.y.z release, update changelogs, and push docs plus tags." diff --git a/.codex/skills/release-version/references/changelog-style.md b/.codex/skills/release-version/references/changelog-style.md new file mode 100644 index 0000000..3376da6 --- /dev/null +++ b/.codex/skills/release-version/references/changelog-style.md @@ -0,0 +1,61 @@ +# Changelog Style + +Use this guide when drafting `docs/zh/changelog.md` and `docs/en/changelog.md`. + +## Goal + +Turn Git history into short release notes that explain what changed for adopters. + +## Keep + +- New user-facing features. +- Bug fixes with clear impact. +- Breaking or compatibility-sensitive changes. +- API, configuration, deployment, model, workflow, or schema changes that affect usage. +- Important documentation updates when they unlock new workflows. + +## Drop Or Compress + +- Pure refactors with no visible effect. +- Formatting-only changes. +- Internal rename churn. +- Mechanical dependency updates unless they fix a real issue. + +## Chinese Style + +- Use concise bullets. +- Prefer product or workflow language over commit jargon. +- Start with the effect, then mention the area if needed. +- Keep terms consistent across bullets. + +Example: + +```md +- 优化会话列表查询与筛选逻辑,减少后台定位问题时的人工排查成本。 +- 修复消息发送链路中的异常处理,避免部分失败场景下页面状态不同步。 +``` + +## English Style + +- Mirror the Chinese meaning instead of translating word by word. +- Use direct release-note phrasing. +- Prefer active wording and concrete impact. + +Example: + +```md +- Improved conversation list querying and filtering so operators can locate problem cases faster. +- Fixed error handling in the message send flow to prevent UI state from drifting after partial failures. +``` + +## Grouping Heuristics + +- Merge multiple commits into one bullet when they deliver one outcome. +- Separate bullets when the audience or impact differs. +- Keep both language versions aligned in bullet count when possible. + +## Before Finalizing + +- Re-check that every bullet is supported by the diff. +- Remove statements that depend on assumptions you cannot verify from code, tests, or commits. +- Keep the notes short enough to scan in under a minute. diff --git a/.codex/skills/release-version/scripts/collect_release_context.py b/.codex/skills/release-version/scripts/collect_release_context.py new file mode 100755 index 0000000..9d95869 --- /dev/null +++ b/.codex/skills/release-version/scripts/collect_release_context.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Collect release context between a target tag and its comparison baseline.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + +def run_git( + repo: Path, + args: list[str], + allow_failure: bool = False, + timeout_seconds: int = 15, +) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired: + if allow_failure: + return "" + raise RuntimeError(f"git command timed out: git {' '.join(args)}") + if result.returncode != 0 and not allow_failure: + raise RuntimeError(result.stderr.strip() or "git command failed") + return result.stdout.strip() + + +def parse_semver(tag: str) -> tuple[int, int, int] | None: + match = SEMVER_TAG_RE.match(tag) + if not match: + return None + return tuple(int(part) for part in match.groups()) + + +def list_reachable_tags(repo: Path) -> list[str]: + output = run_git(repo, ["tag", "--merged", "HEAD"]) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def choose_previous_tag(repo: Path, tags: list[str], target_tag: str | None) -> tuple[str | None, str | None]: + semver_tags: list[tuple[tuple[int, int, int], str]] = [] + other_tags: list[str] = [] + target_semver = parse_semver(target_tag) if target_tag else None + + for tag in tags: + if target_tag and tag == target_tag: + continue + parsed = parse_semver(tag) + if parsed is None: + other_tags.append(tag) + continue + if target_semver is not None and parsed >= target_semver: + continue + semver_tags.append((parsed, tag)) + + if semver_tags: + semver_tags.sort(key=lambda item: item[0], reverse=True) + return semver_tags[0][1], "semver" + + if not other_tags: + return None, None + + candidates: list[tuple[int, str]] = [] + for tag in other_tags: + ts = run_git(repo, ["log", "-1", "--format=%ct", tag], allow_failure=True) + try: + candidates.append((int(ts), tag)) + except ValueError: + continue + if not candidates: + return other_tags[-1], "fallback" + candidates.sort(reverse=True) + return candidates[0][1], "fallback" + + +def ensure_tag_absent(repo: Path, tag: str) -> None: + local = run_git(repo, ["tag", "--list", tag]) + if local: + raise ValueError(f"target tag already exists locally: {tag}") + + remotes_output = run_git(repo, ["remote"]) + for remote in [line.strip() for line in remotes_output.splitlines() if line.strip()]: + remote_hit = run_git( + repo, + ["ls-remote", "--tags", remote, tag], + allow_failure=True, + timeout_seconds=8, + ) + if remote_hit: + raise ValueError(f"target tag already exists on remote {remote}: {tag}") + + +def ensure_tag_exists(repo: Path, tag: str) -> None: + hit = run_git(repo, ["rev-parse", "--verify", "--quiet", tag], allow_failure=True) + if not hit: + raise ValueError(f"previous tag does not exist locally: {tag}") + + +def get_commit_list(repo: Path, rev_range: str) -> list[dict[str, str]]: + output = run_git(repo, ["log", "--reverse", "--date=short", "--pretty=format:%H%x09%ad%x09%s", rev_range]) + commits: list[dict[str, str]] = [] + for line in output.splitlines(): + commit_hash, date_str, subject = line.split("\t", 2) + commits.append( + { + "hash": commit_hash, + "short_hash": commit_hash[:7], + "date": date_str, + "subject": subject, + } + ) + return commits + + +def get_changed_files(repo: Path, rev_range: str) -> list[str]: + output = run_git(repo, ["diff", "--name-only", rev_range]) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def get_numstat(repo: Path, rev_range: str) -> dict[str, int]: + output = run_git(repo, ["diff", "--numstat", rev_range]) + changed_files = 0 + insertions = 0 + deletions = 0 + for line in output.splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + added, removed = parts[0], parts[1] + changed_files += 1 + if added.isdigit(): + insertions += int(added) + if removed.isdigit(): + deletions += int(removed) + return { + "changed_files": changed_files, + "insertions": insertions, + "deletions": deletions, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=".", help="Repository root. Defaults to current directory.") + parser.add_argument("--tag", help="Target release tag to validate, for example v1.2.3.") + parser.add_argument("--previous-tag", help="Explicit comparison baseline.") + return parser + + +def main() -> int: + args = build_parser().parse_args() + repo_path = Path(args.repo).expanduser().resolve() + + if not (repo_path / ".git").exists(): + print(json.dumps({"error": f"not a git repository: {repo_path}"})) + return 1 + + if args.tag and parse_semver(args.tag) is None: + print(json.dumps({"error": f"invalid tag format: {args.tag}", "expected": "vx.y.z"})) + return 1 + + try: + if args.tag: + ensure_tag_absent(repo_path, args.tag) + if args.previous_tag: + ensure_tag_exists(repo_path, args.previous_tag) + except ValueError as exc: + print(json.dumps({"error": str(exc)})) + return 1 + + tags = list_reachable_tags(repo_path) + previous_tag = args.previous_tag + previous_tag_source = "explicit" if previous_tag else None + + if previous_tag is None: + previous_tag, previous_tag_source = choose_previous_tag(repo_path, tags, args.tag) + + rev_range = "HEAD" + if previous_tag: + rev_range = f"{previous_tag}..HEAD" + + try: + commits = get_commit_list(repo_path, rev_range) + changed_files = get_changed_files(repo_path, rev_range) + stats = get_numstat(repo_path, rev_range) + head_commit = run_git(repo_path, ["rev-parse", "HEAD"]) + except RuntimeError as exc: + print(json.dumps({"error": str(exc)})) + return 1 + + payload: dict[str, object] = { + "repo": str(repo_path), + "target_tag": args.tag, + "previous_tag": previous_tag, + "previous_tag_source": previous_tag_source, + "rev_range": rev_range, + "head_commit": head_commit, + "commit_count": len(commits), + "commits": commits, + "changed_files": changed_files, + } + payload.update(stats) + + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())