Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <package-dir>/v<version> 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 (<pkg>/vX.Y.Z) are immutable: this job fails any push that
# moves or deletes one. Combine with a repository ruleset for enforcement.
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<package-dir>/v<version>` 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
Expand Down Expand Up @@ -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 `<package>/v<version>`.
5. Raise the package's version in the same pull request; once it merges, CI
tags the release `<package>/v<version>`.

Package ids are first-come within this repo, must not collide with an
existing id even with punctuation stripped (`roll-tables` blocks `rolltables`),
Expand Down
157 changes: 157 additions & 0 deletions tools/release/packages.py
Original file line number Diff line number Diff line change
@@ -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
<directory>/v<version>, 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 "<tag> <commit>" 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 <directory>/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:]))
Loading