Add towncrier fragments and automated lockstep release workflow
Stop shared Unreleased edits that conflict across PRs, and cut releases via Prepare release (towncrier + every plugin/marketplace version bump) then auto-tag on merge, with agent-oriented CONTRIBUTING and PR template guidance.
This commit is contained in:
@@ -2,18 +2,39 @@
|
||||
|
||||
<!-- What does this PR do? 1-3 sentences. -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Bullet list of what changed. Reference files if helpful. -->
|
||||
|
||||
-
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How did you verify this works? -->
|
||||
- [ ] `uv run pytest`
|
||||
- [ ] Added or updated tests that would catch a regression, or explained why not below
|
||||
|
||||
- [ ] Ran `uv run python -m pytest -q --tb=short`
|
||||
## Changelog
|
||||
|
||||
## Related Issues
|
||||
If this change should appear in the next release notes, add a fragment under `changelog.d/` (see `changelog.d/README.md` and [CONTRIBUTING.md](../CONTRIBUTING.md)). Do **not** edit `CHANGELOG.md` or bump version/manifest files in this PR.
|
||||
|
||||
<!-- Link issues: Fixes #123 or Relates to #456 -->
|
||||
- [ ] Added `changelog.d/<pr-or-issue>.<type>.md` (types: `added`, `changed`, `fixed`, `removed`, `deprecated`, `security`)
|
||||
- [ ] Skip changelog — chore/internal only (also add the `skip-changelog` label)
|
||||
|
||||
## Agent disclosure
|
||||
|
||||
### AI review
|
||||
|
||||
Summarize the review your coding agent ran: main risks checked, what it flagged, and what you changed or verified as a result.
|
||||
|
||||
### Security
|
||||
|
||||
Note any input handling, command execution, path handling, auth, secrets, or dependency risks reviewed, plus follow-up needed. Write `N/A` if none apply.
|
||||
|
||||
## Notes
|
||||
|
||||
Call out follow-up work, host-specific behavior, or risks.
|
||||
|
||||
### Relationship to this change
|
||||
|
||||
Disclose employment, contracting, equity, or other paid ties to a company/product/service this PR adds or meaningfully promotes (example: you work at the API vendor being integrated).
|
||||
|
||||
- [ ] None
|
||||
- [ ] Yes — disclosure: <!-- who / what relationship -->
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- Fixes #123 / Relates to #456 — or N/A -->
|
||||
|
||||
@@ -12,6 +12,7 @@ This file contains Copilot-specific additions. See AGENTS.md for the shared cros
|
||||
Before suggesting a pull request:
|
||||
|
||||
- Confirm that pytest passes.
|
||||
- For changes that belong in the next release notes, add a `changelog.d/<n>.<type>.md` fragment (do not edit `CHANGELOG.md` or bump version manifests). See `CONTRIBUTING.md` / `AGENTS.md` § Changelog and releases and fill the PR template’s Agent disclosure + Relationship sections.
|
||||
- If changes were made anywhere under skills/last30days/, confirm the install copy has been refreshed with:
|
||||
|
||||
npx skills add . -g -y
|
||||
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare a lockstep release: towncrier changelog + bump every version surface.
|
||||
|
||||
Usage (from repo root):
|
||||
python3 .github/scripts/prepare_release.py --bump patch
|
||||
python3 .github/scripts/prepare_release.py --version 3.19.0
|
||||
python3 .github/scripts/prepare_release.py --bump minor --dry-run
|
||||
|
||||
Do not edit CHANGELOG.md or version manifests in feature PRs — add a
|
||||
changelog.d/ fragment instead. This script is for release PRs only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
SKILL_MD = ROOT / "skills" / "last30days" / "SKILL.md"
|
||||
PYPROJECT = ROOT / "pyproject.toml"
|
||||
UV_LOCK = ROOT / "uv.lock"
|
||||
|
||||
JSON_VERSION_FILES = (
|
||||
ROOT / ".claude-plugin" / "plugin.json",
|
||||
ROOT / ".codex-plugin" / "plugin.json",
|
||||
ROOT / ".grok-plugin" / "plugin.json",
|
||||
ROOT / "gemini-extension.json",
|
||||
)
|
||||
|
||||
MARKETPLACE_FILES = (
|
||||
ROOT / ".claude-plugin" / "marketplace.json",
|
||||
ROOT / ".grok-plugin" / "marketplace.json",
|
||||
)
|
||||
|
||||
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||||
_PYPROJECT_VERSION_RE = re.compile(
|
||||
r'^(version\s*=\s*")([^"]+)(")\s*$', re.MULTILINE
|
||||
)
|
||||
_SKILL_FRONTMATTER_VERSION_RE = re.compile(
|
||||
r'^(version:\s*")([^"]+)(")\s*$', re.MULTILINE
|
||||
)
|
||||
_SKILL_HEADER_RE = re.compile(
|
||||
r"^(# last30days v)(\d+\.\d+\.\d+)(:)", re.MULTILINE
|
||||
)
|
||||
_UV_LOCK_PACKAGE_RE = re.compile(
|
||||
r'(?ms)^(\[\[package\]\]\nname = "last30days-skill"\nversion = ")([^"]+)(")'
|
||||
)
|
||||
|
||||
|
||||
def _parse_version(text: str) -> tuple[int, int, int]:
|
||||
match = _VERSION_RE.fullmatch(text.strip())
|
||||
if not match:
|
||||
raise SystemExit(f"Invalid semver (expected X.Y.Z): {text!r}")
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
def _format_version(parts: tuple[int, int, int]) -> str:
|
||||
return f"{parts[0]}.{parts[1]}.{parts[2]}"
|
||||
|
||||
|
||||
def read_current_version() -> str:
|
||||
text = PYPROJECT.read_text(encoding="utf-8")
|
||||
match = _PYPROJECT_VERSION_RE.search(text)
|
||||
if not match:
|
||||
raise SystemExit("Could not find [project].version in pyproject.toml")
|
||||
return match.group(2)
|
||||
|
||||
|
||||
def next_version(current: str, bump: str) -> str:
|
||||
major, minor, patch = _parse_version(current)
|
||||
if bump == "major":
|
||||
return _format_version((major + 1, 0, 0))
|
||||
if bump == "minor":
|
||||
return _format_version((major, minor + 1, 0))
|
||||
if bump == "patch":
|
||||
return _format_version((major, minor, patch + 1))
|
||||
raise SystemExit(f"Unknown bump kind: {bump!r}")
|
||||
|
||||
|
||||
def _replace_once(path: Path, pattern: re.Pattern[str], new: str, label: str) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated, count = pattern.subn(rf"\g<1>{new}\g<3>", text, count=1)
|
||||
if count != 1:
|
||||
raise SystemExit(f"{path.relative_to(ROOT)}: expected one {label} match, found {count}")
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def bump_pyproject(version: str) -> None:
|
||||
_replace_once(PYPROJECT, _PYPROJECT_VERSION_RE, version, "version")
|
||||
|
||||
|
||||
def bump_skill_md(version: str) -> None:
|
||||
text = SKILL_MD.read_text(encoding="utf-8")
|
||||
text2, n1 = _SKILL_FRONTMATTER_VERSION_RE.subn(
|
||||
rf"\g<1>{version}\g<3>", text, count=1
|
||||
)
|
||||
text3, n2 = _SKILL_HEADER_RE.subn(rf"\g<1>{version}\g<3>", text2, count=1)
|
||||
if n1 != 1 or n2 != 1:
|
||||
raise SystemExit(
|
||||
f"SKILL.md: expected one frontmatter version and one H1 version, "
|
||||
f"found frontmatter={n1} header={n2}"
|
||||
)
|
||||
SKILL_MD.write_text(text3, encoding="utf-8")
|
||||
|
||||
|
||||
def bump_json_version(path: Path, version: str) -> None:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if "version" not in data:
|
||||
raise SystemExit(f"{path.relative_to(ROOT)}: missing top-level version")
|
||||
data["version"] = version
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def bump_marketplace(path: Path, version: str) -> None:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
plugins = data.get("plugins") or []
|
||||
if not plugins:
|
||||
raise SystemExit(f"{path.relative_to(ROOT)}: plugins[] is empty")
|
||||
plugins[0]["version"] = version
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def bump_uv_lock(version: str) -> None:
|
||||
text = UV_LOCK.read_text(encoding="utf-8")
|
||||
updated, count = _UV_LOCK_PACKAGE_RE.subn(rf"\g<1>{version}\g<3>", text, count=1)
|
||||
if count != 1:
|
||||
raise SystemExit(f"uv.lock: expected one last30days-skill package stanza, found {count}")
|
||||
UV_LOCK.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def run_towncrier(version: str, *, dry_run: bool) -> None:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"towncrier",
|
||||
"build",
|
||||
"--version",
|
||||
version,
|
||||
"--yes",
|
||||
]
|
||||
if dry_run:
|
||||
cmd.append("--draft")
|
||||
subprocess.run(cmd, cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def bump_all(version: str) -> list[str]:
|
||||
touched: list[str] = []
|
||||
bump_pyproject(version)
|
||||
touched.append(str(PYPROJECT.relative_to(ROOT)))
|
||||
bump_skill_md(version)
|
||||
touched.append(str(SKILL_MD.relative_to(ROOT)))
|
||||
for path in JSON_VERSION_FILES:
|
||||
bump_json_version(path, version)
|
||||
touched.append(str(path.relative_to(ROOT)))
|
||||
for path in MARKETPLACE_FILES:
|
||||
bump_marketplace(path, version)
|
||||
touched.append(str(path.relative_to(ROOT)))
|
||||
bump_uv_lock(version)
|
||||
touched.append(str(UV_LOCK.relative_to(ROOT)))
|
||||
return touched
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--bump", choices=("major", "minor", "patch"))
|
||||
group.add_argument("--version", help="Explicit X.Y.Z to set")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the planned version and towncrier draft; do not write files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-towncrier",
|
||||
action="store_true",
|
||||
help="Only bump version surfaces (changelog already prepared)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
current = read_current_version()
|
||||
version = args.version or next_version(current, args.bump)
|
||||
_parse_version(version)
|
||||
if _parse_version(version) <= _parse_version(current) and args.version:
|
||||
# Allow equal only for dry-run rebuild experiments; refuse downgrades.
|
||||
if _parse_version(version) < _parse_version(current):
|
||||
raise SystemExit(f"Refusing to downgrade {current} → {version}")
|
||||
|
||||
print(f"Current version: {current}")
|
||||
print(f"Next version: {version}")
|
||||
|
||||
if args.dry_run:
|
||||
if not args.skip_towncrier:
|
||||
run_towncrier(version, dry_run=True)
|
||||
print("Dry run only — no files written.")
|
||||
return 0
|
||||
|
||||
if not args.skip_towncrier:
|
||||
run_towncrier(version, dry_run=False)
|
||||
print("Updated CHANGELOG.md via towncrier")
|
||||
|
||||
touched = bump_all(version)
|
||||
print("Bumped lockstep files:")
|
||||
for path in touched:
|
||||
print(f" - {path}")
|
||||
print(f"\nNext: open a release PR, merge, then tag v{version} (tag-release workflow).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
name: Changelog guard
|
||||
|
||||
# Non-release PRs must not edit CHANGELOG.md or bump lockstep version strings.
|
||||
# Content edits to SKILL.md / pyproject.toml / uv.lock are fine.
|
||||
# Release PRs (label: release) are exempt. Engine changes need a changelog
|
||||
# fragment unless labeled skip-changelog.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce changelog / version lockstep rules
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
LABELS="$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels" --jq '.[].name')"
|
||||
TITLE="$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '.title')"
|
||||
IS_RELEASE=0
|
||||
SKIP_CHANGELOG=0
|
||||
if printf '%s\n' "${LABELS}" | grep -qx 'release'; then
|
||||
IS_RELEASE=1
|
||||
fi
|
||||
if printf '%s\n' "${TITLE}" | grep -qE '^chore\(release\): bump version to '; then
|
||||
IS_RELEASE=1
|
||||
fi
|
||||
if printf '%s\n' "${LABELS}" | grep -qx 'skip-changelog'; then
|
||||
SKIP_CHANGELOG=1
|
||||
fi
|
||||
|
||||
mapfile -t CHANGED < <(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")
|
||||
|
||||
changed_changelog=0
|
||||
for path in "${CHANGED[@]}"; do
|
||||
if [ "${path}" = "CHANGELOG.md" ]; then
|
||||
changed_changelog=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${IS_RELEASE}" -eq 1 ]; then
|
||||
echo "PR has label 'release' — version/CHANGELOG edits allowed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${changed_changelog}" -eq 1 ]; then
|
||||
echo "::error::Do not edit CHANGELOG.md in feature PRs."
|
||||
echo "Add changelog.d/<n>.<type>.md instead (see changelog.d/README.md)."
|
||||
echo "Release PRs created via Actions → Prepare release use the 'release' label."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version_at() {
|
||||
local ref="$1"
|
||||
local path="$2"
|
||||
git show "${ref}:${path}" 2>/dev/null | PATH_ARG="${path}" python3 -c '
|
||||
import json, os, re, sys
|
||||
path = os.environ["PATH_ARG"]
|
||||
text = sys.stdin.read()
|
||||
if path.endswith("pyproject.toml"):
|
||||
m = re.search(r"(?m)^version\s*=\s*\"([^\"]+)\"\s*$", text)
|
||||
print(m.group(1) if m else "")
|
||||
elif path.endswith("SKILL.md"):
|
||||
m = re.search(r"(?m)^version:\s*\"([^\"]+)\"\s*$", text)
|
||||
print(m.group(1) if m else "")
|
||||
elif path.endswith("uv.lock"):
|
||||
m = re.search(
|
||||
r"(?ms)^\[\[package\]\]\nname = \"last30days-skill\"\nversion = \"([^\"]+)\"",
|
||||
text,
|
||||
)
|
||||
print(m.group(1) if m else "")
|
||||
elif path.endswith("marketplace.json"):
|
||||
data = json.loads(text)
|
||||
plugins = data.get("plugins") or []
|
||||
print(plugins[0].get("version", "") if plugins else "")
|
||||
else:
|
||||
data = json.loads(text)
|
||||
print(data.get("version", ""))
|
||||
'
|
||||
}
|
||||
|
||||
VERSION_PATHS=(
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
skills/last30days/SKILL.md
|
||||
.claude-plugin/plugin.json
|
||||
.claude-plugin/marketplace.json
|
||||
.codex-plugin/plugin.json
|
||||
.grok-plugin/plugin.json
|
||||
.grok-plugin/marketplace.json
|
||||
gemini-extension.json
|
||||
)
|
||||
|
||||
bumps=()
|
||||
for path in "${VERSION_PATHS[@]}"; do
|
||||
# Only compare when the file exists on both sides.
|
||||
if ! git cat-file -e "${BASE_SHA}:${path}" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
if ! git cat-file -e "${HEAD_SHA}:${path}" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
base_v="$(version_at "${BASE_SHA}" "${path}")"
|
||||
head_v="$(version_at "${HEAD_SHA}" "${path}")"
|
||||
if [ -n "${base_v}" ] && [ -n "${head_v}" ] && [ "${base_v}" != "${head_v}" ]; then
|
||||
bumps+=("${path}: ${base_v} → ${head_v}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#bumps[@]}" -gt 0 ]; then
|
||||
echo "::error::Non-release PRs must not bump lockstep version strings."
|
||||
printf ' - %s\n' "${bumps[@]}"
|
||||
echo "Run Actions → Prepare release to cut a version bump PR."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
has_fragment=0
|
||||
for path in "${CHANGED[@]}"; do
|
||||
case "${path}" in
|
||||
changelog.d/*.md)
|
||||
base="$(basename "${path}")"
|
||||
if [ "${base}" != "README.md" ]; then
|
||||
has_fragment=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
touches_engine=0
|
||||
for path in "${CHANGED[@]}"; do
|
||||
case "${path}" in
|
||||
skills/last30days/scripts/*|skills/last30days/SKILL.md|mcp/*)
|
||||
touches_engine=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${touches_engine}" -eq 1 ] && [ "${has_fragment}" -eq 0 ] && [ "${SKIP_CHANGELOG}" -eq 0 ]; then
|
||||
echo "::error::Engine/skill changes need a changelog.d fragment (or the skip-changelog label)."
|
||||
echo "See changelog.d/README.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Changelog guard passed."
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Prepare release
|
||||
|
||||
# Opens a chore(release) PR that runs towncrier + lockstep version bumps.
|
||||
# Merge of that PR is tagged by tag-release.yml; tag push runs release.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: Semver bump kind (ignored when version is set)
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
default: patch
|
||||
version:
|
||||
description: Optional explicit X.Y.Z (overrides bump)
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install project (towncrier)
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Prepare release files
|
||||
id: prep
|
||||
env:
|
||||
BUMP: ${{ inputs.bump }}
|
||||
EXPLICIT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${EXPLICIT_VERSION}" ]; then
|
||||
uv run python .github/scripts/prepare_release.py --version "${EXPLICIT_VERSION}"
|
||||
VERSION="${EXPLICIT_VERSION}"
|
||||
else
|
||||
uv run python .github/scripts/prepare_release.py --bump "${BUMP}"
|
||||
VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=release/v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release branch and PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.prep.outputs.version }}
|
||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
|
||||
echo "Branch ${BRANCH} already exists on origin — aborting to avoid clobbering."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git switch -c "${BRANCH}"
|
||||
git add \
|
||||
CHANGELOG.md \
|
||||
changelog.d \
|
||||
pyproject.toml \
|
||||
uv.lock \
|
||||
skills/last30days/SKILL.md \
|
||||
.claude-plugin/plugin.json \
|
||||
.claude-plugin/marketplace.json \
|
||||
.codex-plugin/plugin.json \
|
||||
.grok-plugin/plugin.json \
|
||||
.grok-plugin/marketplace.json \
|
||||
gemini-extension.json
|
||||
git status --short
|
||||
if git diff --cached --quiet; then
|
||||
echo "No release changes staged (empty changelog.d?)."
|
||||
exit 1
|
||||
fi
|
||||
git commit -m "chore(release): bump version to ${VERSION}"
|
||||
git push -u origin HEAD
|
||||
|
||||
gh label create release --description "Automated version lockstep release PR" --color 0E8A16 2>/dev/null || true
|
||||
gh label create skip-changelog --description "PR has nothing for release notes" --color BFDADC 2>/dev/null || true
|
||||
|
||||
BODY="$(cat <<EOF
|
||||
## Summary
|
||||
|
||||
Automated release preparation for **v${VERSION}**.
|
||||
|
||||
- Built \`CHANGELOG.md\` from \`changelog.d/\` via towncrier
|
||||
- Bumped every lockstep version surface (skill, pyproject, plugin/marketplace manifests, uv.lock)
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] \`uv run pytest\` (CI)
|
||||
- [ ] Confirm \`tests/test_plugin_contract.py::test_versions_match_across_manifests\` passes
|
||||
- [ ] After merge, confirm tag \`v${VERSION}\` is created and [Release](../actions/workflows/release.yml) attaches artifacts
|
||||
|
||||
EOF
|
||||
)"
|
||||
# Strip leading spaces from heredoc indentation for readable PR body
|
||||
BODY="$(printf '%s\n' "${BODY}" | sed 's/^ //')"
|
||||
|
||||
gh pr create \
|
||||
--title "chore(release): bump version to ${VERSION}" \
|
||||
--body "${BODY}" \
|
||||
--label "release" \
|
||||
--base "${{ github.event.repository.default_branch }}" \
|
||||
--head "${BRANCH}"
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Tag release
|
||||
|
||||
# After a prepare-release PR merges to main, create the vX.Y.Z tag so
|
||||
# release.yml can build .skill / .mcpb artifacts and publish the GitHub Release.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
# Only act on the release-prep commit shape produced by prepare-release.yml
|
||||
# (or an equivalent manual chore(release) commit).
|
||||
if: startsWith(github.event.head_commit.message, 'chore(release): bump version to ')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create annotated tag
|
||||
env:
|
||||
HEAD_MSG: ${{ github.event.head_commit.message }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(printf '%s\n' "${HEAD_MSG}" | head -n1 | sed -n 's/^chore(release): bump version to \([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p')"
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "Could not parse version from commit message: ${HEAD_MSG}"
|
||||
exit 1
|
||||
fi
|
||||
TAG="v${VERSION}"
|
||||
|
||||
PY_VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
|
||||
if [ "${PY_VERSION}" != "${VERSION}" ]; then
|
||||
echo "Commit message version (${VERSION}) does not match pyproject.toml (${PY_VERSION})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Tag ${TAG} already exists locally — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists on origin — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
echo "Created and pushed ${TAG}"
|
||||
@@ -10,7 +10,10 @@ Agent Skills package for researching any topic across Reddit, X, YouTube, and we
|
||||
- `docs/solutions/` — documented solutions to past problems (bugs, best practices, workflow patterns), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`)
|
||||
- `CONCEPTS.md` — shared domain vocabulary (Skill, Engine, Harness, Beta channel) — relevant when orienting to the codebase or discussing project terminology
|
||||
- `CONFIGURATION.md` — user-facing knobs (env vars, flags, per-host install patterns); keep in sync per the rules below
|
||||
- `CHANGELOG.md` — structured release history (launch copy lives in GitHub Releases)
|
||||
- `CHANGELOG.md` — structured release history built by towncrier at release time (launch copy lives in GitHub Releases)
|
||||
- `changelog.d/` — per-PR news fragments; feature PRs write here, never edit `CHANGELOG.md` directly
|
||||
- `CONTRIBUTING.md` — setup, fragments, and release notes for humans and agents (towncrier is release-only)
|
||||
- `.github/scripts/prepare_release.py` — lockstep version bump + towncrier build (release PRs only)
|
||||
- `HERMES_SETUP.md` — install instructions for the Hermes harness specifically
|
||||
|
||||
## Orientation
|
||||
@@ -32,10 +35,24 @@ uv run pytest # full suite
|
||||
uv run pytest tests/test_dedupe_v3.py # single file
|
||||
uv run pytest tests/test_dedupe_v3.py -k some_case # single case
|
||||
uv run pytest --cov # with coverage (skips lib/vendor/)
|
||||
|
||||
# Release prep (maintainers / release automation — not feature PRs):
|
||||
# Prefer GitHub Actions → "Prepare release". Local equivalent:
|
||||
uv run python .github/scripts/prepare_release.py --bump patch # or --version X.Y.Z
|
||||
```
|
||||
|
||||
Python 3.12+ required. Use `uv` for the env; the venv lives at `.venv/`.
|
||||
|
||||
## Changelog and releases (agents)
|
||||
|
||||
Agents open most PRs. Follow this so `CHANGELOG.md` stops conflicting and versions stay lockstep:
|
||||
|
||||
1. **Feature/fix PRs:** add `changelog.d/<pr-or-issue>.<type>.md` (`added` / `changed` / `fixed` / `removed` / `deprecated` / `security`) when the change belongs in the next release notes. See `changelog.d/README.md` and `CONTRIBUTING.md`. Fill the PR template’s Summary, Agent disclosure, and Relationship sections.
|
||||
2. **Never** edit `CHANGELOG.md` in a feature PR. **Never** bump version strings in `pyproject.toml`, `SKILL.md`, plugin/marketplace JSON, or `uv.lock` outside a release PR. CI (`changelog-guard.yml`) enforces this.
|
||||
3. **Nothing for release notes:** omit the fragment, check Skip changelog in the template, and add the `skip-changelog` label.
|
||||
4. **Cutting a release:** run Actions → **Prepare release** (patch/minor/major). That opens a `chore(release): bump version to X.Y.Z` PR which runs towncrier and bumps every lockstep surface. Merging to `main` triggers **Tag release**, which pushes `vX.Y.Z` and existing `release.yml` publishes `.skill` / `.mcpb` artifacts. Do not hand-edit ten version files. Contributors do not need a global towncrier install — `uv sync --group dev` (or the Action) provides it for release prep only.
|
||||
5. Lockstep gate remains `tests/test_plugin_contract.py::test_versions_match_across_manifests`. Workflow contract: `tests/test_changelog_workflow.py`.
|
||||
|
||||
## Rules
|
||||
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
||||
- One-time setup: `npx skills add . -g -y` copies the skill into `~/.agents/skills/<name>/` (real directory) and, for harnesses that support symlinked skill dirs, drops a per-host symlink pointing at that copy. **Working-tree edits do NOT propagate automatically** — the `~/.agents/skills/<name>/` copy is frozen at install time. To sync after edits, re-run `npx skills add . -g -y`. For live-edit on a dev machine, replace the install copy with a symlink to the working tree: `ln -sfn "$PWD/skills/last30days" ~/.agents/skills/last30days` (run from the repo root).
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
This project uses [towncrier](https://towncrier.readthedocs.io/). Upcoming notes live in [`changelog.d/`](changelog.d/); do not edit this file in feature PRs.
|
||||
|
||||
<!-- towncrier release notes start -->
|
||||
|
||||
## [3.18.1] - 2026-07-24
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for helping with last30days. Most PRs here are opened by coding agents following [`AGENTS.md`](AGENTS.md); this file is the short path for humans and agents alike.
|
||||
|
||||
## Setup
|
||||
|
||||
Python **3.12+**. From the repo root:
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
That installs pytest/coverage and **towncrier** into the project env. You do **not** need a global towncrier install for normal contributions.
|
||||
|
||||
## Day-to-day PRs (no towncrier CLI)
|
||||
|
||||
1. Make your change and add/update tests.
|
||||
2. If it should show up in the next release notes, add a fragment:
|
||||
```bash
|
||||
# Prefer the PR or issue number when you know it:
|
||||
# changelog.d/<number>.<type>.md
|
||||
# Orphan (no number yet):
|
||||
# changelog.d/+short-slug.<type>.md
|
||||
```
|
||||
Types: `added`, `changed`, `fixed`, `removed`, `deprecated`, `security`.
|
||||
Details: [`changelog.d/README.md`](changelog.d/README.md).
|
||||
3. Fill out [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md) — Summary (“what does this PR do”), Testing, Changelog, Agent disclosure, Relationship.
|
||||
4. Do **not** edit `CHANGELOG.md` and do **not** bump version strings in `pyproject.toml`, `SKILL.md`, plugin/marketplace JSON, or `uv.lock`. CI enforces that.
|
||||
|
||||
Chores with nothing for the release notes: check Skip changelog in the template and add the `skip-changelog` label.
|
||||
|
||||
Fragments are plain Markdown files. **towncrier is only used when cutting a release** (locally via `uv run` or in GitHub Actions) — contributors never run it for a feature PR.
|
||||
|
||||
## Releases (maintainers)
|
||||
|
||||
Prefer **Actions → Prepare release** (patch / minor / major). That opens a lockstep version PR (towncrier builds `CHANGELOG.md`, bumps every plugin/marketplace surface). Merging to `main` tags `vX.Y.Z` and the existing Release workflow publishes artifacts.
|
||||
|
||||
Local equivalent (after `uv sync --group dev`):
|
||||
|
||||
```bash
|
||||
uv run python .github/scripts/prepare_release.py --bump patch # or --version X.Y.Z
|
||||
```
|
||||
|
||||
More detail: `AGENTS.md` § Changelog and releases, and `docs/solutions/workflow-issues/towncrier-lockstep-release.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
uv run pytest tests/test_dedupe_v3.py -k some_case
|
||||
uv run pytest --cov
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
Never commit real API keys, cookies, tokens, or `.env` contents. Use dummy values in tests and fixtures.
|
||||
@@ -363,7 +363,7 @@ MIT license. No tracking. No analytics. Your research stays on your machine. 2,7
|
||||
|
||||
Built with Python 3.12+, yt-dlp, Node.js (vendored Bird client for X search), and ScrapeCreators API. v3 engine architecture by [@j-sperling](https://github.com/j-sperling).
|
||||
|
||||
See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list of community contributors and [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) to open a PR, [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list of community contributors, and [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Release preparation now builds CHANGELOG.md from changelog.d fragments via towncrier and bumps every plugin/marketplace lockstep version surface through an automated Prepare release workflow (no more shared Unreleased edits).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Changelog fragments
|
||||
|
||||
Feature and fix PRs add a fragment here. **Do not edit `CHANGELOG.md` or bump version manifests** — the release workflow does that.
|
||||
|
||||
You do **not** need the towncrier CLI to contribute. Fragments are ordinary Markdown files; towncrier runs only when a release is prepared. See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
## Create a fragment
|
||||
|
||||
```bash
|
||||
# Prefer the PR or issue number when you know it:
|
||||
# changelog.d/<number>.<type>.md
|
||||
# Orphan (no linked issue/PR yet):
|
||||
# changelog.d/+.<type>.md or changelog.d/+short-slug.<type>.md
|
||||
```
|
||||
|
||||
### Types (Keep a Changelog)
|
||||
|
||||
| Suffix | Section |
|
||||
|--------|---------|
|
||||
| `security` | Security |
|
||||
| `removed` | Removed |
|
||||
| `deprecated` | Deprecated |
|
||||
| `added` | Added |
|
||||
| `changed` | Changed |
|
||||
| `fixed` | Fixed |
|
||||
|
||||
### Content
|
||||
|
||||
One or a few sentences of what shipped — behavior, docs, or install impact someone would care about in release notes. Link issues in the fragment body if useful; towncrier also links the number from the filename.
|
||||
|
||||
```markdown
|
||||
General reports no longer promote unanchored fallback entity misses into synthesis.
|
||||
```
|
||||
|
||||
### Skip
|
||||
|
||||
Pure chores (typos in comments, CI pin bumps with nothing for release notes) can omit a fragment and check **Skip changelog** in the PR template, or add the `skip-changelog` label.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Towncrier fragments + automated lockstep release PRs
|
||||
date: 2026-07-24
|
||||
category: docs/solutions/workflow-issues
|
||||
module: ci-release-engineering
|
||||
problem_type: workflow_issue
|
||||
component: release_workflow
|
||||
severity: medium
|
||||
applies_when:
|
||||
- multiple PRs edit CHANGELOG.md ## [Unreleased] and conflict on merge
|
||||
- a release must bump the same semver across skill, pyproject, and every plugin/marketplace manifest
|
||||
- agents (not humans) author most feature PRs and need a clear changelog rule
|
||||
symptoms:
|
||||
- Unreleased section merge conflicts on every release train
|
||||
- missed marketplace JSON version bumps when releasing by hand
|
||||
- agents invent release steps that drift from test_plugin_contract lockstep
|
||||
root_cause: missing_workflow_step
|
||||
resolution_type: workflow_change
|
||||
related_components:
|
||||
- development_workflow
|
||||
- documentation
|
||||
- github_actions
|
||||
tags:
|
||||
- changelog
|
||||
- towncrier
|
||||
- release-engineering
|
||||
- version-lockstep
|
||||
- agents
|
||||
- github-actions
|
||||
---
|
||||
|
||||
# Towncrier fragments + automated lockstep release PRs
|
||||
|
||||
## Context
|
||||
|
||||
Every feature PR used to edit `CHANGELOG.md` under `## [Unreleased]`, which produced constant merge conflicts. Separately, a correct release must bump the **same** semver across skill frontmatter + H1, `pyproject.toml`, `uv.lock`, Claude/Codex/Grok/Gemini plugin manifests, and both marketplace JSON files — enforced by `tests/test_plugin_contract.py`. Hand-rolled release PRs missed files; release-please would work only with a large `extra-files` surface and conventional-commit discipline that agent traffic does not reliably provide.
|
||||
|
||||
## Solution
|
||||
|
||||
1. **towncrier** — PRs add `changelog.d/<n>.<type>.md`; `CHANGELOG.md` is written only at release time.
|
||||
2. **`.github/scripts/prepare_release.py`** — runs `towncrier build` then bumps every lockstep path.
|
||||
3. **Actions → Prepare release** — opens the release PR; **Tag release** creates `vX.Y.Z` on merge; existing **Release** workflow attaches artifacts.
|
||||
4. **changelog-guard** — blocks non-release edits to `CHANGELOG.md` and version *strings*; requires a fragment (or `skip-changelog`) for engine/skill changes.
|
||||
5. **PR template** — changelog checklist, agent disclosure (AI review + security), and relationship disclosure for contributors tied to a vendor/product they are adding.
|
||||
|
||||
## Agent rules (short)
|
||||
|
||||
- Write fragments, not `CHANGELOG.md`.
|
||||
- Do not bump versions in feature PRs.
|
||||
- Cut releases via Prepare release, not by editing ten files.
|
||||
|
||||
## See also
|
||||
|
||||
- `AGENTS.md` § Changelog and releases
|
||||
- `changelog.d/README.md`
|
||||
- `tests/test_changelog_workflow.py`
|
||||
@@ -10,8 +10,48 @@ dependencies = []
|
||||
dev = [
|
||||
"pytest>=9.1.1,<10",
|
||||
"pytest-cov>=7,<8",
|
||||
"towncrier>=25.8.0,<26",
|
||||
]
|
||||
|
||||
[tool.towncrier]
|
||||
name = "last30days-skill"
|
||||
directory = "changelog.d"
|
||||
filename = "CHANGELOG.md"
|
||||
start_string = "<!-- towncrier release notes start -->\n"
|
||||
underlines = ["", "", ""]
|
||||
title_format = "## [{version}] - {project_date}"
|
||||
issue_format = "[#{issue}](https://github.com/mvanhorn/last30days-skill/issues/{issue})"
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "security"
|
||||
name = "Security"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "removed"
|
||||
name = "Removed"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "deprecated"
|
||||
name = "Deprecated"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "added"
|
||||
name = "Added"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "changed"
|
||||
name = "Changed"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "fixed"
|
||||
name = "Fixed"
|
||||
showcontent = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Contract tests for towncrier + lockstep release preparation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_prepare_release():
|
||||
path = ROOT / ".github" / "scripts" / "prepare_release.py"
|
||||
spec = importlib.util.spec_from_file_location("prepare_release", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestChangelogWorkflow(unittest.TestCase):
|
||||
def test_towncrier_config_present(self) -> None:
|
||||
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
self.assertIn("[tool.towncrier]", text)
|
||||
self.assertIn('directory = "changelog.d"', text)
|
||||
self.assertIn('filename = "CHANGELOG.md"', text)
|
||||
for fragment_type in (
|
||||
"security",
|
||||
"removed",
|
||||
"deprecated",
|
||||
"added",
|
||||
"changed",
|
||||
"fixed",
|
||||
):
|
||||
self.assertIn(f'directory = "{fragment_type}"', text)
|
||||
|
||||
def test_changelog_has_towncrier_start_marker(self) -> None:
|
||||
text = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
self.assertIn("<!-- towncrier release notes start -->", text)
|
||||
self.assertNotIn("## [Unreleased]", text)
|
||||
|
||||
def test_changelog_d_readme_exists(self) -> None:
|
||||
self.assertTrue((ROOT / "changelog.d" / "README.md").is_file())
|
||||
|
||||
def test_prepare_release_script_exists(self) -> None:
|
||||
self.assertTrue((ROOT / ".github" / "scripts" / "prepare_release.py").is_file())
|
||||
|
||||
def test_release_workflows_exist(self) -> None:
|
||||
workflows = ROOT / ".github" / "workflows"
|
||||
for name in (
|
||||
"prepare-release.yml",
|
||||
"tag-release.yml",
|
||||
"changelog-guard.yml",
|
||||
):
|
||||
self.assertTrue((workflows / name).is_file(), msg=name)
|
||||
|
||||
def test_pr_template_has_agent_and_relationship_sections(self) -> None:
|
||||
text = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("## Agent disclosure", text)
|
||||
self.assertIn("### Relationship to this change", text)
|
||||
self.assertIn("changelog.d/", text)
|
||||
self.assertIn("What does this PR do?", text)
|
||||
self.assertTrue((ROOT / "CONTRIBUTING.md").is_file())
|
||||
|
||||
def test_next_version_bumps(self) -> None:
|
||||
mod = _load_prepare_release()
|
||||
self.assertEqual(mod.next_version("3.18.1", "patch"), "3.18.2")
|
||||
self.assertEqual(mod.next_version("3.18.1", "minor"), "3.19.0")
|
||||
self.assertEqual(mod.next_version("3.18.1", "major"), "4.0.0")
|
||||
|
||||
def test_bump_all_updates_lockstep_surfaces(self) -> None:
|
||||
mod = _load_prepare_release()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
# Minimal fixtures mirroring the lockstep layout.
|
||||
(tmp_path / "skills" / "last30days").mkdir(parents=True)
|
||||
(tmp_path / ".claude-plugin").mkdir()
|
||||
(tmp_path / ".codex-plugin").mkdir()
|
||||
(tmp_path / ".grok-plugin").mkdir()
|
||||
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "last30days-skill"\nversion = "3.18.1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "skills" / "last30days" / "SKILL.md").write_text(
|
||||
'---\nversion: "3.18.1"\n---\n\n# last30days v3.18.1: Title\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
for rel in (
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".grok-plugin/plugin.json",
|
||||
"gemini-extension.json",
|
||||
):
|
||||
(tmp_path / rel).write_text(
|
||||
json.dumps({"name": "last30days", "version": "3.18.1"}, indent=2)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for rel in (
|
||||
".claude-plugin/marketplace.json",
|
||||
".grok-plugin/marketplace.json",
|
||||
):
|
||||
(tmp_path / rel).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "last30days-skill",
|
||||
"plugins": [{"name": "last30days", "version": "3.18.1"}],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "uv.lock").write_text(
|
||||
'version = 1\n\n[[package]]\nname = "last30days-skill"\n'
|
||||
'version = "3.18.1"\nsource = { virtual = "." }\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Point module paths at the temp tree.
|
||||
mod.ROOT = tmp_path
|
||||
mod.SKILL_MD = tmp_path / "skills" / "last30days" / "SKILL.md"
|
||||
mod.PYPROJECT = tmp_path / "pyproject.toml"
|
||||
mod.UV_LOCK = tmp_path / "uv.lock"
|
||||
mod.JSON_VERSION_FILES = (
|
||||
tmp_path / ".claude-plugin" / "plugin.json",
|
||||
tmp_path / ".codex-plugin" / "plugin.json",
|
||||
tmp_path / ".grok-plugin" / "plugin.json",
|
||||
tmp_path / "gemini-extension.json",
|
||||
)
|
||||
mod.MARKETPLACE_FILES = (
|
||||
tmp_path / ".claude-plugin" / "marketplace.json",
|
||||
tmp_path / ".grok-plugin" / "marketplace.json",
|
||||
)
|
||||
|
||||
touched = mod.bump_all("9.9.9")
|
||||
self.assertEqual(len(touched), 9)
|
||||
|
||||
pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
|
||||
self.assertIn('version = "9.9.9"', pyproject)
|
||||
|
||||
skill = (tmp_path / "skills" / "last30days" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn('version: "9.9.9"', skill)
|
||||
self.assertIn("# last30days v9.9.9:", skill)
|
||||
|
||||
for rel in (
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".grok-plugin/plugin.json",
|
||||
"gemini-extension.json",
|
||||
):
|
||||
data = json.loads((tmp_path / rel).read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["version"], "9.9.9", msg=rel)
|
||||
|
||||
for rel in (
|
||||
".claude-plugin/marketplace.json",
|
||||
".grok-plugin/marketplace.json",
|
||||
):
|
||||
data = json.loads((tmp_path / rel).read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["plugins"][0]["version"], "9.9.9", msg=rel)
|
||||
|
||||
uv_lock = (tmp_path / "uv.lock").read_text(encoding="utf-8")
|
||||
self.assertRegex(
|
||||
uv_lock,
|
||||
re.compile(
|
||||
r'(?ms)^\[\[package\]\]\nname = "last30days-skill"\nversion = "9\.9\.9"',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -106,13 +106,14 @@ class TestPluginContract(unittest.TestCase):
|
||||
self.assertIn("plugins", marketplace)
|
||||
|
||||
def test_workflows_do_not_reference_removed_root_scripts_dir(self) -> None:
|
||||
# The root-level scripts/ directory was removed; workflows must not
|
||||
# reference it. Subdirectory scripts/ paths (skills/last30days/scripts/
|
||||
# for the Code-skill build, mcp/scripts/ for the .mcpb build) are
|
||||
# the legitimate replacements.
|
||||
# The historical root-level scripts/ directory was removed; workflows must not
|
||||
# reference a bare `scripts/` path. Allowed replacements:
|
||||
# skills/last30days/scripts/ (engine), mcp/scripts/ (.mcpb), .github/scripts/
|
||||
# (release automation).
|
||||
allowed_prefixes = (
|
||||
"skills/last30days/scripts/",
|
||||
"mcp/scripts/",
|
||||
".github/scripts/",
|
||||
)
|
||||
offenders = []
|
||||
for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")):
|
||||
|
||||
@@ -2,6 +2,18 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -104,6 +116,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "last30days-skill"
|
||||
version = "3.18.1"
|
||||
@@ -113,6 +137,7 @@ source = { virtual = "." }
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "towncrier" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -121,6 +146,70 @@ dev = [
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.1.1,<10" },
|
||||
{ name = "pytest-cov", specifier = ">=7,<8" },
|
||||
{ name = "towncrier", specifier = ">=25.8.0,<26" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -179,3 +268,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "towncrier"
|
||||
version = "25.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "jinja2" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user