Automate the complete release process for the Octop Python package.
At the start, announce: "Using the publish skill to release version {VERSION}."
Configuration items
The following configurations have default values and can be overridden in the project's .cursor/skills/publish/SKILL.md .
| Configuration items | Default values | Description |
|---|---|---|
CHANGELOG_FILE |
CHANGELOG.md |
Path relative to the repository root; skipped if the file does not exist |
VERSION_FILE |
pyproject.toml |
File containing the version number |
VERSION_PATTERN |
^\s*version\s*=\s*"[^"]+" |
Regex matching the version line |
README_GLOB |
README*.md |
README with a shields.io version badge (including multilingual variants, such as README.md、README_CN.md); all are upgraded in sync |
INIT_VERSION_FILE |
src/octop/__init__.py |
File containing the runtime constant __version__ (skipped with a warning if missing) |
TAG_PREFIX |
v |
Git tag prefix; the workflow listens for v*, generating v0.1.14 -style tags |
REMOTE |
origin |
Git remote repository name |
INTEGRATION_BRANCH |
develop |
Daily integration branch; releases must be cut from the latest tip |
TARGET_BRANCH |
main |
Target branch for merge requests (the production source of truth) |
RELEASE_BRANCH_PREFIX |
release/ |
Release branch name prefix |
Usage
/publish 0.1.14
The target version number is the only required parameter; all others are obtained from configuration or auto-detected.
Release process
Strict sequential constraints: First merge the PR into
main, then on main tip create and push thev*tag。
Prohibitedto push a production tag before the release branch is merged intomain.
Step 1 — Read configuration and confirm the version
- Get the repository root:
git rev-parse --show-toplevel - Record the current branch as
{original_branch}。 git fetch {REMOTE} {INTEGRATION_BRANCH} {TARGET_BRANCH}- From
VERSION_FILEread the current version (on the integration tip about to be based on):git show {REMOTE}/{INTEGRATION_BRANCH}:pyproject.toml | grep -E '^\s*version\s*=\s*"[^"]+"' - Check for uncommitted changes:
If the working tree is not clean:Abortand ask the user to commit or stash first. Releases must not carry unrelated dirty files.git status --short - Find the most recent git tag:
If there is no tag, treat it as the first release (in step 3, use all commits from the repository's initial state to HEAD).git tag --sort=-creatordate | head -1 - Display confirmation information:
当前版本 (pyproject.toml @ develop): X.Y.Z
目标版本: A.B.C
上次发布 tag: vX.Y.Z (YYYY-MM-DD)
Release 分支: release/A.B.C
集成起点: develop
合入目标: main
Tag 时机: main 合并之后(不会在 release 上先打 tag)
确认发布 X.Y.Z → A.B.C?[y/N]
If the user does not enter y to confirm, abort immediately.
Step 2 — Create the release branch from develop
git checkout -B {RELEASE_BRANCH_PREFIX}{version} {REMOTE}/{INTEGRATION_BRANCH}
If a release branch with the same name already exists locally or remotely, abort:
✗ 分支 {RELEASE_BRANCH_PREFIX}{version} 已存在。
请手动删除后再运行 /publish。
Step 3 — Analyze changes and generate a CHANGELOG draft
-
Get the commits since the last tag (relative to the current release HEAD, i.e. the develop tip):
git log {last_tag}..HEAD --oneline # 首次发布时: git log --oneline -
If no commits are found:
⚠ 自上次发布 tag ({last_tag}) 以来没有新提交。 是否继续?[y/N]Abort if the user does not confirm.
-
Classify by commit prefix and generate entries in Keep a Changelog format.
The CHANGELOG content must be written in Chinese. Summarize each commit as a concise Chinese bullet point — do not translate commit messages verbatim. Merge related commits as appropriate.
Classification rules:
- Starts with
feat:orfeat(→ Added - Starts with
fix:orfix(→ Fixed - Starts with
refactor:orperf:→ Changed - Contains
!:or commit body containsBREAKING CHANGE:→ Change, add**Breaking:**prefix - starts with
docs:→ Change - starts with
chore:、test:、ci:→ ignore (infrastructure noise) - all other commits → Change
- removed features → Removed
- security fixes → Security
Output format:
## [A.B.C] - YYYY-MM-DD ### 新增 - 中文描述新增功能 ### 修复 - 中文描述修复内容 ### 变更 - 中文描述行为变更 ### 移除 - 中文描述移除内容 ### 安全 - 中文描述安全修复Omit empty categories. Use ISO 8601 format for the date (today's date). Keep a blank line between the date line and the first category, and between categories.
- Starts with
-
Show the draft to the user and request confirmation:
CHANGELOG 草稿: {draft} 添加到 CHANGELOG.md?[y/N/edit]y→ continuen→ aborteditor other feedback → ask the user: "What changes are needed?" — wait for a reply, regenerate, and show the confirmation again. Loop untilyorn。
-
If
CHANGELOG_FILEdoes not exist, skip this step (no warning needed).
Step 4 — Update files, commit, and push the release branch
Execute in order:
4a. Update CHANGELOG:
In CHANGELOG_FILE find the ## [Unreleased] heading, and insert the new version entry after it (keep [Unreleased] empty):
## [Unreleased]
## [A.B.C] - YYYY-MM-DD
### 新增
- ...
If the ## [Unreleased] heading does not exist, insert the new entry after the # Changelog heading line (or at the top of the file if there is no heading).
4b. Bump the version number (replace all old X.Y.Z numbers with the current A.B.C):
Pinned numbers representing the "current release version" must be consistent,do not change only pyproject.toml.
Replace the old version X.Y.Z with the target version A.B.C. If a file is missing, warn and continue (do not abort).
Required changes list (in order, use the Edit tool for precise replacements; do not use a global replace that would damage historical CHANGELOG entries):
VERSION_FILE(pyproject.toml) — the sole version source for wheel / PyPI:grep -n '^\s*version\s*=\s*"[^"]+"' pyproject.toml # "X.Y.Z" → "A.B.C"- All READMEs matching
README_GLOBand containing the shields.io version badge (including multilingual ones):
The current repository includes at leastgrep -l 'shields.io/badge/version-' README.md README_*.md 2>/dev/null grep -n 'shields.io/badge/version-' {file} # `version-X.Y.Z-orange` → `version-A.B.C-orange`README.mdandREADME_CN.md. If none match, warn and continue. INIT_VERSION_FILE(src/octop/__init__.py) — runtime constant__version__:grep -n '__version__' src/octop/__init__.py # `__version__ = "X.Y.Z"` → `"A.B.C"`- The
version=(scripts/build-fpk.shof the two FnOS manifests will be injected again during packaging, but the repository source files must be changed first):grep -n '^version=' fnos/docker/manifest fnos/native/manifest # `version=X.Y.Z` → `version=A.B.C`(两个文件都要改) - FnOS Docker compose image tag (same as this package version, for FPK to pull from GHCR):
grep -n 'ghcr.io/tencentcloud/octop:' fnos/docker/app/docker/docker-compose.yaml # `ghcr.io/tencentcloud/octop:X.Y.Z` → `:A.B.C` # 不得改成 `:latest`(测试禁止 latest) uv.lockeditable package inoctopversion (missing this will cause the lock and pyproject to be inconsistent):grep -n -A2 'name = "octop"' uv.lock | head -5 # 将该包的 `version = "X.Y.Z"` 改为 `"A.B.C"` # 或在改完 pyproject.toml 后执行 `uv lock`,只接受 octop 版本行变化
Cleanup (required): Search the entire repository for the old number, and change any missed "current version pins" as well:
git grep -n --fixed-strings "X.Y.Z"
For each match:
| Type | Action |
|---|---|
Current version pin (compose / badge / manifest / lock / __version__ / "current version" in docs) |
Replace with A.B.C |
| Hardcoded assertions in tests that "equal the current release number" | Change to read _pyproject_version()(or sync to A.B.C) |
CHANGELOG.md released section (## [X.Y.Z] and body) |
KeepDo not change history |
Use old numbers for comparison in test fixtures (e.g. 1.0.2b4 vs 1.0.2b5, parsing examples) |
Keep |
Example old numbers in workflow / comments (e.g. 0.9.28) |
Keep(not currently pinned) |
Before committing, scan the must-change checklist again; there must be no old numbers remaining:
git grep -n --fixed-strings "X.Y.Z" -- \
pyproject.toml src/octop/__init__.py \
README.md README_CN.md \
fnos/docker/manifest fnos/native/manifest \
fnos/docker/app/docker/docker-compose.yaml \
uv.lock
If any file still matches an old number:Fix and commit again before submitting, do not push a release with a half-updated version number.
4c. Commit:
git status --short
- If there are changes: stage and commit:
git add -A git commit -m "chore: release {version}" - If the working tree is already clean: no commit needed, skip.
4d. Push the release branch:
git push -u {REMOTE} {RELEASE_BRANCH_PREFIX}{version}
If the push fails, abort.
Step 5 — Create the Pull Request to merge into main (merge first, then auto-tag)
Use gh CLI:
gh pr create \
--base {TARGET_BRANCH} \
--head {RELEASE_BRANCH_PREFIX}{version} \
--title "chore: release {version}" \
--body "$(cat <<'EOF'
{步骤 3 生成的 CHANGELOG 条目}
## Release checklist
- [ ] CI green
- [ ] Merge this PR into main
- [ ] After merge, GitHub Action auto-pushes v{version} tag on main tip
EOF
)"
- On success, display the PR URL and clearly state:
- Do not manually create the tag before merging;
- after merging, the release will be published automatically (the tag will be created automatically).
- If
ghfails: abort (no release has been made at this point), and prompt to create the PR manually:{RELEASE_BRANCH_PREFIX}{version}→{TARGET_BRANCH}
Step 6 — After the merge, the Action creates the tag on the main tip
-
Ask the user whether the PR has been merged, or poll:
gh pr view {pr_url} --json state,mergedAtIf not merged, wait; there is no need to create the tag locally.
-
After merging:
auto-tag-on-release.ymlwill read the mergedmain'spyproject.tomlversion and push{TAG_PREFIX}{version}。- If the tag already exists, the Action will skip and output a log.
-
Prompt the user to confirm in Actions:
Auto Tag On Release Mergesucceeded;ReleaseandDocker Publishare triggered by thev*tag and pass;Sync Main Into Developafter the GitHub Release is published, syncsmainback todevelop。
Step 7 — Delete the release branch; develop is synced by the Action
-
Delete the remote and local release branches:
git push {REMOTE} --delete {RELEASE_BRANCH_PREFIX}{version} git branch -D {RELEASE_BRANCH_PREFIX}{version}If deletion fails, warn (non-fatal) and prompt for manual deletion.
-
develop sync: after the GitHub Release is published successfully,
sync-main-to-develop.ymlwill automatically open amain → developPR and, if there are no conflicts, merge it using merge commit .- If it is already fast-forwarded with no diff, the Action will skip.
- If there are conflicts or branch protection blocks it, the Action leaves the PR and raises an alert, requiring manual handling.
- The skill side does not need to manually create the sync PR again (unless the Action fails).
Step 8 — Switch back to the original branch
git checkout {original_branch}
Ensure that after the process ends, the user is not left on the release / temporary checkout.
Error Handling Reference
| Scenario | Behavior |
|---|---|
VERSION_FILE not found |
Abort: "VERSION_FILE not found: {path}" |
| No version number matched in the file | Abort: "Could not find a version line matching {VERSION_PATTERN} in {VERSION_FILE}" |
| Working tree not clean | Abort: clean up first, then release |
| No git tag (first release) | Use the full history; prompt "first release" |
| No commits since the last tag | Warn and ask whether to continue |
| Release branch already exists | Abort and provide deletion instructions |
| Step 4 push failed | Abort: files already updated locally but not pushed |
| Step 5 PR creation failed | Abort (tag not yet created / not yet released) |
| Step 6 manually create tag when not merged | Prohibited — hard red line |
| Step 6 tag already exists | Abort and provide deletion instructions |
| Step 6 tag pushed successfully but Action failed | Non-fatal: prompt to Actions Re-run |
| Step 7 delete branch or sync PR failed | Warn and provide manual commands |
Red line rules
Never:
- On release / feature branches, before merging
mainbeforepush productionv*tag - Upload directly to PyPI before pushing the tag (release is handled by GitHub Action)
- Push
developdirectly push / merge intomain(must go through PR) - Skip step 1 user confirmation
- Skip step 3 CHANGELOG confirmation
- Continue execution after any step fails (except step 7 cleanup/sync warnings)
- Leave the user on the release branch after the process ends
- Keep the already-released
release/*as a long-term branch
Always:
- Cut release from the latest
{REMOTE}/{INTEGRATION_BRANCH}cut release - Merge
{TARGET_BRANCH}first, then the Action creates the tag on main tip - Delete after release
release/*;main → developis automatically synced bysync-main-to-develop.yml(manually supplement if it fails) - Show full error output before aborting
- After inserting the new version entry, keep
[Unreleased]empty - Replace the old version number
X.Y.ZwithA.B.C:pyproject.toml、__version__, multilingual README badges,fnos/*/manifest、fnos/docker/app/docker/docker-compose.yamlimage tags,uv.lock's octop package version; before committinggit grepmust-change checklist confirms no old version numbers - Do not modify CHANGELOG historical sections, old version comparisons in test fixtures, or workflow example numbers
- After pushing the tag, prompt the user to watch the GitHub Actions release result