This skill automates the full release workflow for a single-package GitHub repository,
from analysis through changelog authoring and PR creation. It relies exclusively on
gh (GitHub CLI) and git no other tools needed.
Steps 1 - 4 are read-only reconnaissance nothing is written to the repo until
Step 5, once the version number is confirmed.
Use this skill whenever the user wants to cut a new release, publish a new version,
bump a version, create a release branch, generate a changelog, or open a release PR
on a GitHub repository. Trigger even if the user says something casual like "let's
ship a new version" or "time to release".
Examples below include both Bash and PowerShell variants; Windows users should prefer
the PowerShell blocks.
Before starting, verify the environment:
gh auth status # must be authenticated
gh repo view --json nameWithOwner # must be inside a GitHub repo
git status # working tree should be clean
If any check fails, stop and tell the user what to fix before continuing.
Then ask the user one question:
"Which directory contains your library's public-facing source code?
(e.g. src/, lib/, pkg/ - used to focus the diff on what consumers
actually see. Press Enter to scan the whole repo.)"
Store the answer as PUBLIC_PATH. If empty, PUBLIC_PATH is . (repo root).
Exclude these paths from all diffs regardless: tests/, test/, spec/,
__tests__/, docs/, *.lock, *-lock.json, *.sum, generated files
(files with a "do not edit" header comment), and build artefacts.
Before using PUBLIC_PATH in any command, validate it contains only safe path characters:
if [[ "$PUBLIC_PATH" =~ [^a-zA-Z0-9_./ -] ]]; then
echo "Error: PUBLIC_PATH contains invalid characters. Only alphanumerics, dots, hyphens, underscores, spaces, and forward slashes are permitted." >&2
exit 1
fi
If validation fails, stop and ask the user to provide a valid relative path (e.g. src/, lib/, .). Do not proceed until a clean value is confirmed.
Work through every step in order. Show the user what command you're about to run and
its output. Pause and ask for confirmation only when explicitly noted.
git checkout main
git pull origin main
Stay on main for now. The release branch is created in Step 5, after the version
is confirmed.
Why not gh release list? GitHub Releases are an optional layer on top of Git
tags. Many repos tag releases with git tag without ever creating a GitHub Release,
so gh release list can return empty even when version tags exist. Reading tags
directly from git is the reliable source of truth.
# Fetch all tags from remote to ensure local view is current
git fetch --tags
# Find the latest version tag, sorted semantically
# --sort=-version:refname handles 1.10.0 > 1.9.0 correctly (unlike alphabetical)
PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+' | head -1)
echo "Latest tag: $PREV_TAG"
# Fetch all tags from remote to ensure local view is current
git fetch --tags
# Find the latest version tag, sorted semantically
# --sort=-version:refname handles 1.10.0 > 1.9.0 correctly (unlike alphabetical)
$prevTag = git tag --sort='-version:refname' | `
Select-String '^[vV]?\d+\.\d+\.\d+' | `
Select-Object -First 1 -ExpandProperty Line
if ($prevTag) {
$prevSha = git rev-list -n 1 $prevTag
} else {
$prevSha = git rev-list --max-parents=0 HEAD
}
Write-Output "Latest tag: $prevTag"
Then verify the tag exists on the remote (not just locally):
git ls-remote --tags origin | grep -Fx "refs/tags/$PREV_TAG"
If the remote check returns nothing, warn the user that the tag appears to be local-only
and hasn't been pushed - they may want to push it before continuing.
PREV_TAG is the tag name exactly as found (e.g. v1.4.2). Strip any leading v
when doing arithmetic; preserve it when naming things.
- If no tags exist at all, treat
PREV_TAG as (none), set PREV_SHA to the
first commit, and default the new version to 1.0.0 (skip Step 4 versioning logic;
go straight to Step 5).
- If the tag does not point to a real commit (orphaned tag), fall back to
git rev-list --max-parents=0 HEAD and warn the user.
PREV_SHA=$(git rev-list -n 1 "$PREV_TAG" 2>/dev/null || git rev-list --max-parents=0 HEAD)
This step uses two complementary signals. The code diff is the primary source of
truth; commit messages provide supporting context about intent.
# Focused diff on the public source path, excluding noise
git diff "$PREV_SHA"..HEAD -- "$PUBLIC_PATH" \
':(exclude)tests/' ':(exclude)test/' ':(exclude)spec/' \
':(exclude)__tests__/' ':(exclude)docs/' \
':(exclude)*.lock' ':(exclude)*-lock.json' ':(exclude)*.sum'
# Focused diff on the public source path, excluding noise
git diff "$($prevSha)..HEAD" -- $publicPath `
':(exclude)tests/' ':(exclude)test/' ':(exclude)spec/' `
':(exclude)__tests__/' ':(exclude)docs/' `
':(exclude)*.lock' ':(exclude)*-lock.json' ':(exclude)*.sum'
Read the full diff output. For each changed file, identify:
- Removed symbols - functions, classes, methods, constants, exported names that
existed before and are now gone. → Strong signal for MAJOR.
- Changed signatures - functions that exist in both versions but with different
parameters, return types, or thrown errors. → Strong signal for MAJOR.
- New exported symbols - public functions, classes, constants that didn't exist
before. → Signal for MINOR.
- Internal-only changes - modifications that don't touch any public interface
(private helpers, unexported functions, algorithm internals). → PATCH.
- Bug fixes - corrections to logic that was provably wrong (e.g. off-by-one,
null check, wrong condition), without changing the public API. → PATCH.
If the diff is very large (thousands of lines), first run the stat summary to
prioritise which files to read in full:
git diff "$PREV_SHA"..HEAD --stat -- "$PUBLIC_PATH"
Focus your detailed reading on files with the most changes and files whose names
suggest they define public interfaces (e.g. index.*, api.*, exports.*,
public.*, mod.*, __init__.*).
git log "$PREV_SHA"..HEAD --oneline --no-merges
Use this to:
- Understand the intent behind code changes that aren't self-explanatory from
the diff alone (e.g. a one-line security fix labelled as such).
- Catch changes that may be in paths outside
PUBLIC_PATH but are still user-visible
(e.g. a CLI flag change in a cmd/ directory).
- Fill in context for changelog entries where the code alone doesn't tell the whole
story.
Look for Conventional Commit prefixes like feat: (new feature → MINOR), fix: (bug fix → PATCH), and BREAKING CHANGE: footer or ! suffix (breaking change → MAJOR) to quickly classify commits. A refactor: or chore: prefix with no public API changes maps to PATCH. When the prefix is absent, rely on the code diff as the primary signal.
When signals agree → use that classification with confidence.
When signals conflict → prefer the code diff. Examples:
- Commit says
fix: typo but the diff shows a removed public method → treat as MAJOR.
- Commit says
feat: new API but the diff only touches private internals → treat as PATCH.
- Commit says
chore: refactor but the diff adds new exported symbols → treat as MINOR.
Document any conflicts you notice - flag them to the user during the changelog review
in Step 6.
Apply these rules to your analysis from Step 3:
| Condition | Bump |
|---|
Any breaking change to public API (removal, signature change, behaviour change) | MAJOR |
New exported symbol or feature, no breaking changes | MINOR |
Bug fix, perf improvement, security fix, docs, chore only | PATCH |
When a release contains a mix, the highest precedence wins:
MAJOR > MINOR > PATCH.
Compute NEXT_VERSION:
- Split
PREV_TAG into MAJOR.MINOR.PATCH integers.
- Apply the appropriate bump.
- Format as
vMAJOR.MINOR.PATCH.
Present the proposed version to the user with a brief rationale that cites
specific code findings, not just commit messages. Example:
"I'm proposing v2.1.0. The diff shows two new exported functions (NewClient and
WithTimeout) in src/client.go, and no existing public symbols were removed or
changed. Commit messages corroborate this as feature additions."
Ask: "Does this version look right, or would you like to adjust it?"
Wait for confirmation before proceeding.
Now that the version is confirmed, create the branch with the correct name from the start:
git checkout -b release/vX.Y.Z
git push -u origin release/vX.Y.Z
Read the existing CHANGELOG.md (or create it if absent). Follow the
Keep a Changelog format strictly.
Structure to insert at the top (just below the # Changelog header):
## [X.Y.Z] - YYYY-MM-DD
### Added
- ...
### Changed
- ...
### Deprecated
- ...
### Removed
- ...
### Fixed
- ...
### Security
- ...
Rules:
Show the user the proposed changelog section before writing it to disk.
If any signal conflicts were found in Step 3c, flag them here so the user can verify.
Ask: "Does this changelog look accurate? Any entries to add, remove, or reword?"
Incorporate feedback, then write to disk.
git add CHANGELOG.md
git commit -m "chore: release vX.Y.Z"
git push origin release/vX.Y.Z
Confirm the push succeeded before moving on.
⚠️ Security check before publishing: The changelog is generated from git diff and git log output. Before pasting changelog content into the PR body, you MUST manually scan it for any accidentally committed secrets, API keys, tokens, or other sensitive data. Check whether the diff touched any of the following:
.env, .env.*, *.pem, *.key, *.p12, *.pfx, id_rsa, id_ed25519 files
Configuration files that may contain connection strings, API keys, tokens, or passwords
Any file whose diff contains patterns like Bearer, token=, secret=, password=, api_key=, PRIVATE KEY
If any such content is present in the changelog section, do not paste it into the PR body. Alert the user, stop, and ask them to remove the sensitive content from the commit history (e.g. via git rebase or BFG) before continuing. A public PR body cannot be unpublished from audit logs.
Always use --body-file to pass PR body text, never --body with inline text.
Inline escape sequences like \n are not interpreted as newlines by PowerShell and will appear
as literal text in the PR. Using a file ensures proper markdown formatting.
# Create a temporary file for the PR body to ensure correct formatting
cat > release_pr_body.md <<'EOF'
## Release vX.Y.Z
This PR prepares the **vX.Y.Z** release.
### What's included
<!-- paste the changelog section here -->
### Checklist
- [ ] Changelog reviewed
- [ ] Version bump verified
- [ ] CI passing
After merging, create the tag on the merge commit:
git tag vX.Y.Z <merge-commit-sha>
git push origin vX.Y.Z
EOF
# Create the PR using the file
gh pr create \
--base main \
--head release/vX.Y.Z \
--title "Release vX.Y.Z" \
--body-file release_pr_body.md
# Clean up the temporary file
rm release_pr_body.md
# Create PR body using here-string (preserves actual newlines, not escape sequences)
$prBody = @"
## Release vX.Y.Z
This PR prepares the **vX.Y.Z** release.
### What's included
<paste changelog here>
### Checklist
- [ ] Changelog reviewed
- [ ] Version bump verified
- [ ] CI passing
After merging, create the tag on the merge commit:
```
git tag vX.Y.Z <merge-commit-sha>
git push origin vX.Y.Z
```
"@
# Write to file and use --body-file (do NOT use inline --body with escape sequences)
$prBody | Out-File -FilePath release_pr_body.md -Encoding utf8 -NoNewline
gh pr create --base main --head release/vX.Y.Z --title "Release vX.Y.Z" --body-file release_pr_body.md
# Clean up the temporary file
Remove-Item release_pr_body.md -ErrorAction SilentlyContinue
Only paste the changelog section into the PR body's "What's included" block after completing the security check above. If any potentially sensitive content was found, leave the placeholder and alert the user instead.
Tell the user:
Release PR is open! 🎉
New version:
vX.Y.Z
Once the PR is reviewed and merged, you'll need to
create the tag yourself on
the merge commit:
git tag vX.Y.Z <merge-commit-sha>
git push origin vX.Y.Z
Then go to GitHub Releases and publish the release from that tag. You can copy the
changelog section directly into the release notes.
| Situation | What to do |
|---|
gh auth status fails
| Stop; tell user to run gh auth login |
Not inside a git repo | Stop; tell user to cd into their repo |
Working tree is dirty | Warn; ask if they want to stash or abort |
No commits since last tag | Tell user there's nothing to release |
Tag exists but points to no commit | Use first commit as diff base; warn user |
Latest tag exists locally but not on remote | Warn user; ask if they want to push the tag first or continue anyway |
Diff is empty for PUBLIC_PATH but commits exist | Warn; all changes may be internal; ask if they still want to proceed |
git push fails (e.g. protected branch rules)
| Report the error verbatim; suggest they check branch protection settings |
- If a command that works locally prints gh usage or treats a subcommand as separate token, ensure you're
invoking the gh.exe on PATH (Get-Command gh) and avoid passing unexpanded nested substitutions; use the PowerShell
patterns above.
- Recommend tests: gh --version; git fetch --tags; run the PowerShell snippet to set $prevTag and run git diff --name-only $prevSha..HEAD -- src/
- Requires the
gh CLI to be installed and authenticated.
- Requires git tags to determine current version.