diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c673132..e2ccfa3 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 -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]+$' || 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" + 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,18 +62,69 @@ 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 the README package table matches index.yaml - working-directory: packages run: python3 tools/readme-packages-table.py --check - 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" + # 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 "$drift" + 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 --atomic origin "${tags[@]}" + exit "$drift" + 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 7f092c4..995752a 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,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 @@ -216,7 +229,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 (`roll-tables` blocks `rolltables`), diff --git a/tools/release/packages.py b/tools/release/packages.py new file mode 100755 index 0000000..6a7b0e8 --- /dev/null +++ b/tools/release/packages.py @@ -0,0 +1,157 @@ +#!/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. Exits 1 when a tagged + version's directory changed after its tag. + +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.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: + 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: + # --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() + 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() + drift = 0 + 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"::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: + 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:]))