Agent Skills: OSPREY Release Workflow

>

UncategorizedID: als-apg/osprey/osprey-release

Repository

als-apgLicense: BSD-3-Clause
6328

Install this agent skill to your local

pnpm dlx add-skill https://github.com/als-apg/osprey/tree/HEAD/src/osprey/templates/skills/osprey-release

Skill Files

Browse the full folder contents for osprey-release.

Download Skill

Loading file tree…

src/osprey/templates/skills/osprey-release/SKILL.md

Skill Metadata

Name
osprey-release
Description
>

OSPREY Release Workflow

This skill cuts a properly versioned OSPREY release. Releases are CalVer tags (vYYYY.M.P) on main; the PyPI publish runs automatically when the tag is pushed.

The shape is:

  1. Verify the working state and decide on the version number.
  2. Open a release-notes PR carrying the CHANGELOG, RELEASE_NOTES and README updates (no direct push to main — branch protection rejects it).
  3. Merge the PR to main.
  4. Tag the merge commit and push the tag. This is what sets the version.
  5. Verify the automated GitHub Actions workflow publishes successfully.

For the PR mechanics in step 2, defer to the osprey-contribute skill.

Versioning: CalVer

OSPREY uses CalVer: YYYY.M.P where:

  • YYYY — four-digit year of the release
  • M — calendar month, no zero-padding (e.g., 5, not 05)
  • P — patch counter within the month, starting at 0

Examples: 2026.5.0, 2026.5.1 (patch within May 2026), 2026.6.0 (next month). When the year or month rolls over, P resets to 0.

The Source of Truth

The git tag is the version. There is no version literal anywhere in the tree to edit — hatch-vcs derives the package version from git describe at build time, and osprey.version resolves it at runtime. Tagging v2026.7.0 is the act of setting the version to 2026.7.0.

Between releases the version reports its distance from the last tag (2026.6.2.post783+g83fda5e60), which is how a development build is distinguishable from the release it descends from.

| File | Purpose | Updated by | | --- | --- | --- | | RELEASE_NOTES.md | First-line title with the release version | This skill | | CHANGELOG.md | Add ## [vYYYY.M.P] - YYYY-MM-DD heading; rotate ## [Unreleased] content | This skill | | README.md | "Latest Release" line with version + theme | This skill | | pyproject.toml | [tool.hatch.version] source = "vcs" | Do not edit | | src/osprey/_version.py | Build-time stamp, gitignored | Never commit |

The release.yml verify step builds the package and compares the built wheel's version to the pushed tag — if these disagree, the publish fails. They disagree when the tag is not on the commit being built, or when the checkout is shallow (which yields 0.1.devN rather than failing outright).


Step 0: Read the CHANGELOG and decide the theme

Open CHANGELOG.md, read the ## [Unreleased] section, and answer three questions before doing anything else:

  1. What is this release about? Pick a short theme (e.g., "plan authoring & branch-protection enforcement"). It goes into the release title, the README "Latest Release" line, and the GitHub Release body.
  2. What is the version number? Apply the CalVer rules above. Patch bump for fixes, month bump for feature batches, year bump only at January.
  3. Are there breaking changes? Check the ### Changed and ### Removed sections. If user-facing API changed, the release should call it out prominently and (if it would surprise users) include a migration note.

Confirm theme + version + breaking-changes status with the maintainer before proceeding.

Step 1: Pre-release testing in a clean venv

Your working venv may have packages that aren't declared in pyproject.toml. A clean venv catches missing dependencies before users do:

python -m venv .venv-release-test
source .venv-release-test/bin/activate
pip install -e ".[dev]"

# Unit tests (fast, free)
pytest tests/ --ignore=tests/e2e -v

# E2E tests (~10-12 min, ~$1-2 in API calls — must use path, not marker)
pytest tests/e2e/ -v

deactivate && rm -rf .venv-release-test

Any failures stop the release. Fix forward, then re-run.

Step 2: Release-notes PR

Release-notes commits cannot be pushed directly to main — branch protection rejects it. Open a PR instead.

git checkout main && git pull --ff-only origin main
git checkout -b release/vYYYY.M.P

There is no version literal to edit — the tag in Step 4 sets the version. This PR carries only the human-facing notes. Show the maintainer each diff before applying:

| File | Change | | --- | --- | | RELEASE_NOTES.md | First line: # Osprey Framework - Latest Release (vYYYY.M.P) followed by the theme tagline | | CHANGELOG.md | Convert ## [Unreleased] to ## [YYYY.M.P] - YYYY-MM-DD; insert a fresh empty ## [Unreleased] above it | | README.md | Update the "Latest Release" line with version + theme |

Then run a consistency check — every line should mention the same version:

echo "=== VERSION CONSISTENCY CHECK ==="
echo "RELEASE_NOTES:  $(head -1 RELEASE_NOTES.md)"
echo "README.md:      $(grep 'Latest Release:' README.md)"
echo "CHANGELOG.md:   $(grep -m1 '^## \[' CHANGELOG.md)"

Now hand off to osprey-contribute for the rest of the PR mechanics: quick_check.sh → commit (release: notes for vYYYY.M.P) → ci_check.sh → push → premerge_check.sh maingh pr create.

The PR title should be release: vYYYY.M.P — <theme>. The PR body should include the CHANGELOG entries verbatim so reviewers see exactly what's being released.

Step 3: Merge the PR

After CI passes (all 8 required checks green):

gh pr merge --rebase --delete-branch

Linear history is required, so --rebase. After merge:

git checkout main && git pull --ff-only origin main

Verify the latest commit on main is the version bump.

Step 4: Tag and push

Tags can be pushed directly — branch protection covers branches, not tags:

git tag vYYYY.M.P
git push origin vYYYY.M.P

The tag must point at the merge commit on main. The release.yml workflow triggers on v*.*.* and:

  1. Builds the wheel and sdist.
  2. Verifies the built version matches the tag. A checkout without full history builds 0.1.devN instead of the tagged version; this gate catches that.
  3. Publishes to PyPI via trusted publishing (OIDC; no token needed).
  4. Creates a GitHub Release using the CHANGELOG section as the body.

If step 2 fails, the publish aborts before any PyPI write — safe.

Step 5: Verify

gh run watch                                 # follow the release.yml run
gh release view vYYYY.M.P                    # confirm GitHub Release exists
pip install --upgrade osprey-framework       # in a fresh shell
python -c "import osprey; print(osprey.__version__)"
open https://als-apg.github.io/osprey/        # switcher button reads vYYYY.M.P

Four success signals:

  • release.yml finished green.
  • https://pypi.org/project/osprey-framework/YYYY.M.P/ exists.
  • https://github.com/als-apg/osprey/releases/tag/vYYYY.M.P has the CHANGELOG entries as the body.
  • The version switcher button on https://als-apg.github.io/osprey/ reads vYYYY.M.P, not the previous release. (The dropdown lists the new tag either way; the button is what proves the root was rebuilt.) If it still reads the old release, check the docs runs first (gh run list --workflow=docs.yml --limit 5): if no run for the tag ever started, it was superseded while pending — GitHub keeps one pending run per concurrency group — so gh workflow run docs.yml -f tag=vYYYY.M.P and re-check.

If any fail, stop and investigate before announcing the release. An empty answer — gh run watch finding no matching run, or a command returning nothing on an API hiccup — is neither success nor failure: re-query with an explicit run selector (gh run watch <run-id>) before treating anything as green.


Manual Publish Fallback (only if Actions is broken)

If release.yml is broken and the release is time-sensitive:

rm -rf dist/ build/ src/*.egg-info/
uv build
uvx twine check dist/*
uvx twine upload dist/*    # requires PyPI credentials in env

Then manually create the GitHub Release: gh release create vYYYY.M.P --notes-file <(awk '/^## \[YYYY.M.P\]/,/^## \[/' CHANGELOG.md | head -n -1).

This is a fallback. The default path is the automated workflow.

Common Failure Modes

| Symptom | Cause | Fix | | --- | --- | --- | | release.yml "Verify built version matches tag" fails | The wheel built from the tagged commit carries a different version | The tag points at the wrong commit, or the checkout lacked the history hatch-vcs needs. Delete the tag locally and on origin, fix, retag | | PyPI rejects the upload as a duplicate | This version was already published | CalVer means version numbers are unique; you cannot republish. Bump the patch counter and try again | | gh pr merge --rebase fails with "not mergeable" | Stale checks because main moved | git rebase origin/main on the release branch, force-push with lease, wait for CI to re-run | | GitHub Release body is empty or wrong | CHANGELOG section heading didn't match the regex release.yml uses | Make sure the CHANGELOG heading is exactly ## [YYYY.M.P] - YYYY-MM-DD |

Out of Scope

  • Hotfix branches — OSPREY uses GitHub Flow, no special hotfix branches. A hotfix is just a fix/<short-kebab> branch off main, PR'd back; then this skill cuts a follow-up release.
  • Release candidates / beta tags — not currently supported by release.yml, which triggers on v*.*.* only. If you need an RC channel, the workflow needs changes first.
  • Documentation buildsdocs.yml publishes the docs from the tag on its own: the site root shows the newest release and main publishes at /latest/. Nothing to run by hand unless the root did not pick up the new tag, in which case Step 5's re-dispatch applies.