From 2d891007acae761970b8b1be5d1bec7b5cf0b567 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:25:22 +0000 Subject: [PATCH 1/2] Tag package releases in CI and require a version bump (P3) A pull request that changes a package's directory now fails unless it raises that package's version above the last one. On main, a release job tags /v for every version without a tag, at the last commit that touched the directory. Manifests are validated against SharpMUSH main and, once one exists, SharpMUSH's latest release tag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7UboEPqDhRV5S7thiu --- .github/workflows/validate.yml | 79 ++++++++++++++++- README.md | 16 +++- tools/release/packages.py | 153 +++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 4 deletions(-) create mode 100755 tools/release/packages.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index babbaf7..dd56a31 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,13 +4,38 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read jobs: + engine-refs: + # The SharpMUSH refs to validate against: main (or vars.SHARPMUSH_REF) and, + # once one exists, the latest release tag vX.Y.Z. + runs-on: ubuntu-latest + outputs: + refs: ${{ steps.refs.outputs.refs }} + steps: + - id: refs + env: + MAIN_REF: ${{ vars.SHARPMUSH_REF || 'main' }} + run: | + set -e + release=$(git ls-remote --tags --refs https://github.com/SharpMUSH/SharpMUSH 'v*' \ + | sed 's#.*refs/tags/##' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + refs=$(printf '%s\n' "$MAIN_REF" $release | jq -R . | jq -sc .) + echo "Validating against: $refs" + echo "refs=$refs" >> "$GITHUB_OUTPUT" + validate: + needs: engine-refs runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sharpmush-ref: ${{ fromJSON(needs.engine-refs.outputs.refs) }} + name: validate (SharpMUSH ${{ matrix.sharpmush-ref }}) steps: - name: Checkout packages uses: actions/checkout@v4 @@ -23,7 +48,7 @@ jobs: with: persist-credentials: false repository: SharpMUSH/SharpMUSH - ref: ${{ vars.SHARPMUSH_REF || 'main' }} + ref: ${{ matrix.sharpmush-ref }} path: sharpmush - name: Setup .NET @@ -37,14 +62,62 @@ jobs: working-directory: sharpmush run: dotnet run --project tools/PackageValidator -- ../packages --strict + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout packages + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Check every changed package has a new version + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python3 -I tools/release/packages.py check-bumps "$BASE_SHA" "$HEAD_SHA" + - name: Check PF2e navigation and notice links - working-directory: packages run: python3 tools/check-pf2e-navigation.py - name: Check the PF2e cache version follows the package version - working-directory: packages run: python3 tools/check-pf2e-cache-version.py + release: + # Tags /v for every package version on main that has + # no tag yet, at the last commit that touched the package's directory. + needs: [validate, checks] + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: release-tags + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Tag new package versions + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "41898283+github-actions[bot]@users.noreply.github.com" + pending=$(python3 -I tools/release/packages.py pending-tags) + if [ -z "$pending" ]; then + echo "No new package versions." + exit 0 + fi + tags=() + while read -r tag commit; do + git tag -a "$tag" "$commit" -m "$tag" + echo "Tagged $tag at $commit" + tags+=("refs/tags/$tag") + done <<< "$pending" + git push origin "${tags[@]}" + immutable-tags: # Release tags (/vX.Y.Z) are immutable: this job fails any push that # moves or deletes one. Combine with a repository ruleset for enforcement. diff --git a/README.md b/README.md index b10c37b..39d6cac 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,19 @@ are **immutable**: never move or delete one — publish a fix as a new version. Installers record the exact commit they applied and warn loudly if a release tag later points elsewhere. +Tags are made by CI. A pull request that changes anything in a package's +directory fails unless it raises that package's `version` (in `package.yaml` +and `index.yaml`) above the last one. Once it merges, the release job tags +`/v` at the last commit that touched the directory. +Every manifest is validated against SharpMUSH `main` and, once SharpMUSH has +a release, against its latest one. To check a branch by hand: + +``` +git fetch --tags origin +python3 tools/release/packages.py check-bumps origin/main HEAD +python3 tools/release/packages.py pending-tags +``` + ## The manifest format, in brief Manifests are **desired state**, not command scripts — and they never contain @@ -211,7 +224,8 @@ mode: warnings fail). 4. Open a pull request. Keep attribute namespaces behind a `convention_prefix`, never hardcode dbrefs, declare every `{{?configure}}` ref with a label and type, and use block scalars for all code. -5. Once merged, a maintainer tags the release `/v`. +5. Raise the package's version in the same pull request; once it merges, CI + tags the release `/v`. Package ids are first-come within this repo, must not collide with an existing id even with punctuation stripped (`who-where` blocks `whowhere`), diff --git a/tools/release/packages.py b/tools/release/packages.py new file mode 100755 index 0000000..0ba84d6 --- /dev/null +++ b/tools/release/packages.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Release tooling for the package repository. + +A package is a top-level directory holding a package.yaml. Its release tag is +/v, the name SharpMUSH's Git package source looks for. + + check-bumps BASE HEAD Fail when a package changed between BASE and HEAD + (merge-base diff, as a pull request shows it) without + a higher version, or when its new version is already tagged. + pending-tags Print " " for every package version on HEAD + that has no tag yet. The commit is the last one that + touched the package's directory, so a version is tagged + where its content was last changed. + +Run from the repository root with the tags fetched (git fetch --tags). +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +VERSION_LINE = re.compile(r'^version:\s*["\']?([^"\'\s#]+)', re.M) +VERSION = re.compile(r"^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:-(.+))?$") + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def parse(text: str) -> tuple[int, int, int, str | None] | None: + """The engine's PackageVersion: 1-3 numeric components, optional -prerelease.""" + match = VERSION.match(text.strip()) + if not match: + return None + return int(match[1]), int(match[2] or 0), int(match[3] or 0), match[4] + + +def normalized(version: tuple[int, int, int, str | None]) -> str: + major, minor, patch, pre = version + return f"{major}.{minor}.{patch}" + (f"-{pre}" if pre else "") + + +def compare(a: tuple[int, int, int, str | None], b: tuple[int, int, int, str | None]) -> int: + """SemVer 2.0.0 precedence, matching PackageVersion.CompareTo.""" + if a[:3] != b[:3]: + return -1 if a[:3] < b[:3] else 1 + pa, pb = a[3], b[3] + if pa == pb: + return 0 + if pa is None: + return 1 + if pb is None: + return -1 + for x, y in zip(pa.split("."), pb.split(".")): + if x == y: + continue + xn, yn = x.isdigit(), y.isdigit() + if xn and yn: + return -1 if int(x) < int(y) else 1 + if xn != yn: + return -1 if xn else 1 + return -1 if x < y else 1 + la, lb = len(pa.split(".")), len(pb.split(".")) + return (la > lb) - (la < lb) + + +def version_at(ref: str, directory: str) -> str | None: + """The raw version string in /package.yaml at ref, or None when there is no manifest.""" + try: + text = git("show", f"{ref}:{directory}/package.yaml") + except subprocess.CalledProcessError: + return None + match = VERSION_LINE.search(text) + return match[1] if match else "" + + +def tags() -> set[str]: + return set(git("tag", "--list", "*/v*").split()) + + +def check_bumps(base: str, head: str) -> int: + changed = git("diff", "--name-only", f"{base}...{head}").splitlines() + directories = sorted({path.split("/", 1)[0] for path in changed if "/" in path}) + merge_base = git("merge-base", base, head).strip() + existing = tags() + failures = [] + checked = 0 + for directory in directories: + after_text = version_at(head, directory) + if after_text is None: + continue # not a package on HEAD (removed, or not a package directory) + checked += 1 + after = parse(after_text) + if after is None: + failures.append(f"{directory}: version '{after_text}' is not a package version") + continue + tag = f"{directory}/v{normalized(after)}" + before_text = version_at(merge_base, directory) + if before_text is None: + if tag in existing: + failures.append(f"{directory}: new package, but {tag} already exists") + else: + print(f"OK {directory}: new package at {normalized(after)}") + continue + before = parse(before_text) + if before is not None and compare(after, before) <= 0: + failures.append( + f"{directory}: files changed but the version is still {before_text}; " + f"raise version in {directory}/package.yaml and index.yaml") + elif tag in existing: + failures.append(f"{directory}: {tag} already exists; choose a version above every released one") + else: + print(f"OK {directory}: {before_text} -> {normalized(after)}") + for failure in failures: + print(f"ERROR {failure}") + print(f"::error::{failure}") + print(f"{checked} changed package(s), {len(failures)} without a new version.") + return 1 if failures else 0 + + +def pending_tags() -> int: + existing = tags() + for manifest in sorted(Path(".").glob("*/package.yaml")): + directory = manifest.parent.name + match = VERSION_LINE.search(manifest.read_text(encoding="utf-8")) + version = parse(match[1]) if match else None + if version is None: + print(f"::warning::{directory}: no package version; not tagged", file=sys.stderr) + continue + tag = f"{directory}/v{normalized(version)}" + last = git("log", "-1", "--format=%H", "--", directory).strip() + if tag not in existing: + print(f"{tag} {last}") + elif git("rev-parse", f"{tag}:{directory}") != git("rev-parse", f"HEAD:{directory}"): + print(f"::warning::{directory} changed after {tag} without a new version", file=sys.stderr) + return 0 + + +def main(argv: list[str]) -> int: + match argv: + case ["check-bumps", base, head]: + return check_bumps(base, head) + case ["pending-tags"]: + return pending_tags() + case _: + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From aba05f64a98d681d7135e0d27a9fbff2066e39cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:10:17 +0000 Subject: [PATCH 2/2] Harden the release check after review (P3) - check-bumps diffs with --no-renames -z, so a file moved out of a package counts as a change to it. - pending-tags fails when a tagged version's directory changed after its tag (two merged PRs that raised the same package to the same version); the release job still pushes the other tags, then fails. - Tags are pushed with --atomic; the engine-refs step runs with pipefail so a failed ls-remote fails the job instead of dropping the release ref. - Prerelease identifiers count as numeric only when ASCII digits, as PackageVersion does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7UboEPqDhRV5S7thiu --- .github/workflows/validate.yml | 14 +++++++++----- tools/release/packages.py | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index dd56a31..5bc974b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,9 +21,9 @@ jobs: env: MAIN_REF: ${{ vars.SHARPMUSH_REF || 'main' }} run: | - set -e + set -eo pipefail release=$(git ls-remote --tags --refs https://github.com/SharpMUSH/SharpMUSH 'v*' \ - | sed 's#.*refs/tags/##' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + | sed 's#.*refs/tags/##' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sort -V | tail -1) refs=$(printf '%s\n' "$MAIN_REF" $release | jq -R . | jq -sc .) echo "Validating against: $refs" echo "refs=$refs" >> "$GITHUB_OUTPUT" @@ -105,10 +105,13 @@ jobs: set -e git config user.name "github-actions[bot]" git config user.email "41898283+github-actions[bot]@users.noreply.github.com" - pending=$(python3 -I tools/release/packages.py pending-tags) + # A package changed after its tag without a new version still lets + # the other packages' tags through, then fails the job. + drift=0 + pending=$(python3 -I tools/release/packages.py pending-tags) || drift=1 if [ -z "$pending" ]; then echo "No new package versions." - exit 0 + exit "$drift" fi tags=() while read -r tag commit; do @@ -116,7 +119,8 @@ jobs: echo "Tagged $tag at $commit" tags+=("refs/tags/$tag") done <<< "$pending" - git push origin "${tags[@]}" + git push --atomic origin "${tags[@]}" + exit "$drift" immutable-tags: # Release tags (/vX.Y.Z) are immutable: this job fails any push that diff --git a/tools/release/packages.py b/tools/release/packages.py index 0ba84d6..6a7b0e8 100755 --- a/tools/release/packages.py +++ b/tools/release/packages.py @@ -10,7 +10,8 @@ pending-tags Print " " for every package version on HEAD that has no tag yet. The commit is the last one that touched the package's directory, so a version is tagged - where its content was last changed. + where its content was last changed. Exits 1 when a tagged + version's directory changed after its tag. Run from the repository root with the tags fetched (git fetch --tags). """ @@ -56,7 +57,7 @@ def compare(a: tuple[int, int, int, str | None], b: tuple[int, int, int, str | N for x, y in zip(pa.split("."), pb.split(".")): if x == y: continue - xn, yn = x.isdigit(), y.isdigit() + xn, yn = x.isascii() and x.isdigit(), y.isascii() and y.isdigit() if xn and yn: return -1 if int(x) < int(y) else 1 if xn != yn: @@ -81,7 +82,8 @@ def tags() -> set[str]: def check_bumps(base: str, head: str) -> int: - changed = git("diff", "--name-only", f"{base}...{head}").splitlines() + # --no-renames: a file moved out of a package changes that package too. + changed = [p for p in git("diff", "--name-only", "--no-renames", "-z", f"{base}...{head}").split("\0") if p] directories = sorted({path.split("/", 1)[0] for path in changed if "/" in path}) merge_base = git("merge-base", base, head).strip() existing = tags() @@ -122,6 +124,7 @@ def check_bumps(base: str, head: str) -> int: def pending_tags() -> int: existing = tags() + drift = 0 for manifest in sorted(Path(".").glob("*/package.yaml")): directory = manifest.parent.name match = VERSION_LINE.search(manifest.read_text(encoding="utf-8")) @@ -134,8 +137,9 @@ def pending_tags() -> int: if tag not in existing: print(f"{tag} {last}") elif git("rev-parse", f"{tag}:{directory}") != git("rev-parse", f"HEAD:{directory}"): - print(f"::warning::{directory} changed after {tag} without a new version", file=sys.stderr) - return 0 + print(f"::error::{directory} changed after {tag} without a new version; raise its version", file=sys.stderr) + drift += 1 + return 1 if drift else 0 def main(argv: list[str]) -> int: