diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6de410d..8336478 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,15 @@ name: Publish # Publishes to PyPI when a version tag is pushed, so people can -# `pipx install fxcss` instead of cloning. +# `pipx install fxcss` instead of cloning. The Release workflow tags a release +# when its "Prepare vX.Y.Z" pull request merges and starts this run on the tag. # # Both tag pushes and manual runs must pass the same unit and Firefox smoke # tests as pull requests. A tag must also match the built package's version. # +# Once PyPI serves the release, the follow-up job moves the README's pins to +# it, refreshes fxcss.com and creates the GitHub release if there isn't one. +# # It uses PyPI Trusted Publishing, so there is no API token to store. Configure # the publisher once at https://pypi.org/manage/account/publishing/ with: # owner AdamXweb · repo fxcss · workflow publish.yml · environment pypi @@ -28,6 +32,8 @@ jobs: # try to publish to the upstream project's name. if: github.repository == 'AdamXweb/fxcss' runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 @@ -58,6 +64,9 @@ jobs: f"package version {version!r}; expected v{version}") print(f"Validated fxcss {version} ({ref_type}: {ref_name})") PY + - name: Record the version + id: version + run: echo "version=$(basename dist/*.whl | cut -d- -f2)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v7 with: name: dist @@ -105,3 +114,61 @@ jobs: done echo "::error::fxcss==$version still not installable 10 minutes after upload — do not bump pins off this run" exit 1 + + # Runs only after the gate above, so nothing here advertises an install that + # fails. A push made with GITHUB_TOKEN starts no workflow, so this starts the + # website refresh itself rather than relying on Website docs. The later + # steps still run if an earlier one fails; the job then fails to say so. + follow-up: + needs: [build, publish] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # push the pin update and create the release + actions: write # start Refresh docs + steps: + - uses: actions/checkout@v7 + with: + ref: main + - name: Move the README pins + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + python3 scripts/release.py bump-pins "$VERSION" README.md + if git diff --quiet -- README.md; then + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -q -m "Update the README pins to fxcss $VERSION" -- README.md + for _ in 1 2 3; do + if git push origin HEAD:main; then + echo "::notice title=README pins::Moved to fxcss $VERSION ($(git rev-parse --short HEAD))." + exit 0 + fi + git pull -q --rebase origin main + done + echo "::error title=README pins::Could not push the pin update to main." + exit 1 + - name: Refresh the website + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run refresh-docs.yml --repo "$GITHUB_REPOSITORY" --ref website + - name: Create the GitHub release + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + if gh release view "v$VERSION" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + echo "v$VERSION already has a release." + exit 0 + fi + # --target only applies when the tag doesn't exist yet: a manual run + # from a branch then tags the commit it published. + gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" --title "v$VERSION" --generate-notes + echo "::notice title=GitHub release::Created v$VERSION. Edit its notes on the releases page." diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml new file mode 100644 index 0000000..dac36b8 --- /dev/null +++ b/.github/workflows/release-version.yml @@ -0,0 +1,30 @@ +name: Release version + +# Merging a change to fxcss.__version__ releases that version (see the Release +# workflow), so only a release pull request may make one. Its title must say +# "Prepare vX.Y.Z" for the new version, the version must increase, and it must +# not already be tagged. Pull requests that leave the version alone pass. +# +# It also runs when a pull request is retitled, so fixing the title clears it. + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + version: + name: Check the version change + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check the version change + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: python3 scripts/release.py check-pr "$BASE_SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ecf6371 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release + +# Tags a release when its "Prepare vX.Y.Z" pull request merges, then starts +# Publish on that tag. Publish uploads to PyPI, waits until the release is +# installable, then moves the README's pins, refreshes fxcss.com and creates +# the GitHub release. +# +# A tag pushed with GITHUB_TOKEN starts no workflow, so this starts Publish +# itself. The Release version check stops other pull requests from changing +# __version__; if one is merged anyway, this run fails instead of releasing it. +# +# Run it by hand to release the version already on main: +# gh workflow run release.yml --ref main + +on: + push: + branches: [main] + paths: [fxcss/__init__.py] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + tag: + if: github.repository == 'AdamXweb/fxcss' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write # push the tag + actions: write # start Publish + pull-requests: read # find the pull request this commit came from + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Find the release pull request + id: pr + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + title=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ + --jq '[.[] | select(.merged_at != null)][0].title // ""') + printf 'Pull request: %s\n' "${title:-none}" + # A pull request title is a single line. + printf 'title=%s\n' "$title" >> "$GITHUB_OUTPUT" + - name: Decide whether to release + id: plan + env: + EVENT_NAME: ${{ github.event_name }} + PR_TITLE: ${{ steps.pr.outputs.title }} + run: python3 scripts/release.py plan + - name: Tag the release + if: steps.plan.outputs.release == 'yes' + env: + VERSION: ${{ steps.plan.outputs.version }} + run: | + set -euo pipefail + git tag "v$VERSION" "$GITHUB_SHA" + git push origin "refs/tags/v$VERSION" + - name: Start Publish on the tag + if: steps.plan.outputs.release == 'yes' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.plan.outputs.version }} + run: | + set -euo pipefail + gh workflow run publish.yml --repo "$GITHUB_REPOSITORY" --ref "v$VERSION" + echo "::notice title=Release::Tagged v$VERSION and started Publish." diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 248bd96..f876f65 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -3,17 +3,15 @@ name: Website docs # fxcss.com's documentation is generated from this README, and its homepage # shows the released version. The site lives on the `website` branch, whose # Refresh docs workflow regenerates it from main, checks it and publishes it. -# This starts that workflow whenever one of its inputs changes: -# - a release reaches PyPI (Publish succeeded, including its -# installability gate), which moves the version; -# - README.md changes on main, such as the pin update after a release. +# This starts that workflow whenever README.md changes on main. +# +# After a release, Publish starts it instead, once PyPI serves the new version +# and the README's pins have moved to it. That pin update is pushed with +# GITHUB_TOKEN, which starts no workflow, so it doesn't run this one too. # The refresh skips a version PyPI doesn't serve yet, so README changes merged # between a release's prep and its upload don't advertise it early. on: - workflow_run: - workflows: [Publish] - types: [completed] push: branches: [main] paths: [README.md] @@ -23,9 +21,7 @@ permissions: jobs: refresh: - if: >- - github.repository == 'AdamXweb/fxcss' && - (github.event_name == 'push' || github.event.workflow_run.conclusion == 'success') + if: github.repository == 'AdamXweb/fxcss' runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/README.md b/README.md index 229e900..4cf2943 100644 --- a/README.md +++ b/README.md @@ -1502,7 +1502,7 @@ text. For a custom workflow, the core capture-and-compare steps are: ```yaml -- run: pip install "fxcss[images]==0.24.0" +- run: pip install "fxcss[images]==0.25.0" - run: fxcss shot --theme base --out shots/base - run: fxcss shot --theme head --out shots/head - run: fxcss compare --base shots/base --head shots/head --out out/ --platform ${{ runner.os }} @@ -1657,7 +1657,7 @@ the [releases page](https://github.com/AdamXweb/fxcss/releases) has the latest. CI runners' Pythons are not externally managed, so plain pip is fine there: ```bash -pip install "fxcss[images]==0.24.0" +pip install "fxcss[images]==0.25.0" ``` Upgrade an existing pipx installation with `pipx upgrade fxcss`. @@ -1864,7 +1864,37 @@ workflow tests also use Node and bash when they are installed, and skip otherwise. Offline release orchestration tests execute the workflow scripts with stubbed pip, sleep and gh commands to check tag validation, the PyPI installability retry gate and website dispatch without network access. They -also check that publication still requires CI and the built-distribution checks. +also check that publication still requires CI and the built-distribution checks, +and run the release rules, tagging and the pin update against local Git +repositories. + +### Releasing fxcss + +A release is one pull request. Title it **Prepare vX.Y.Z** and change +`__version__` in `fxcss/__init__.py` to X.Y.Z. Merging it does the rest: + +1. **Release** tags the merge commit `vX.Y.Z` and starts **Publish** on that tag. +2. **Publish** runs CI, checks the built packages, uploads them to PyPI and + waits until pip can install the new version. +3. Publish then moves the README's `fxcss==` pins to the new version, refreshes + fxcss.com and creates the GitHub release with generated notes. You can edit + the notes afterwards. + +Only a release pull request may change `__version__`. The **Release version** +check fails any other pull request that does, and Release won't tag a version +that didn't come from one. While features land, leave the version at the +latest release and write "X.Y.Z or later" in the README where a feature needs +the next one. + +To release the version that is already on main, run Release by hand: + +```bash +gh workflow run release.yml --ref main +``` + +If Publish fails, re-run it from the Actions tab. If the fix needs a code +change, merge the fix, delete the tag with `git push origin :refs/tags/vX.Y.Z`, +then run Release by hand. ## How this was built diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..c00fe60 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,156 @@ +"""Release rules shared by the Release, Release version and Publish workflows. + +A release is one pull request titled "Prepare vX.Y.Z" that changes +fxcss.__version__ to X.Y.Z. Merging it tags vX.Y.Z and starts Publish. Once +PyPI serves the release, Publish moves the README's pins and refreshes the +website. These rules keep each of those steps from being skipped or run for a +version nobody meant to release. + + python3 scripts/release.py check-pr BASE_SHA # Release version (pull requests) + python3 scripts/release.py plan # Release (main) + python3 scripts/release.py bump-pins VERSION README.md + +check-pr reads PR_TITLE from the environment, and plan reads EVENT_NAME and +PR_TITLE, so a title is never interpolated into a shell script. +""" +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = "fxcss/__init__.py" +VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +PIN = re.compile(r"(fxcss(?:\[[A-Za-z0-9_,-]+\])?==)(\d+\.\d+\.\d+)") + + +def parse(version): + match = VERSION.match(version) + if not match: + raise ValueError(f"{version!r} is not a MAJOR.MINOR.PATCH version") + return tuple(int(part) for part in match.groups()) + + +def read_version(text): + match = re.search(r'^__version__ = "([^"]+)"$', text, re.M) + if not match: + raise ValueError(f"no __version__ in {VERSION_FILE}") + parse(match[1]) + return match[1] + + +def released(tags): + """The versions of v-prefixed MAJOR.MINOR.PATCH tags.""" + versions = [] + for tag in tags: + if tag.startswith("v") and VERSION.match(tag[1:]): + versions.append(tag[1:]) + return versions + + +def is_prepare_title(title, version): + """A release PR says "Prepare vX.Y.Z" in its title, as its squash commit will.""" + return re.search(r"\bprepare v" + re.escape(version) + r"(?![\w.])", title or "", re.I) is not None + + +def check_pr(base, head, title, tags): + """Problems with a pull request that changes the version, or [] if none.""" + if base == head: + return [] + problems = [] + if parse(head) <= parse(base): + problems.append(f"The version moves from {base} to {head}; a release must increase it.") + if head in released(tags): + problems.append(f"v{head} is already tagged; choose the next version.") + if not is_prepare_title(title, head): + problems.append( + f"This pull request changes the version from {base} to {head}, so merging it " + f"releases fxcss {head}. Only a release PR may do that: title it " + f"\"Prepare v{head}\", or leave __version__ at {base} and let the release PR move it.") + return problems + + +def plan(version, tags, event, title): + """Return (release, message). A message without a release is a failure only if it says so.""" + versions = released(tags) + if version in versions: + return False, f"fxcss {version} is already tagged; nothing to release." + newest = max(versions, key=parse) if versions else None + if newest and parse(version) <= parse(newest): + return False, (f"error: main is at fxcss {version}, which is not newer than the " + f"latest release, v{newest}.") + if event == "workflow_dispatch" or is_prepare_title(title, version): + return True, f"Releasing fxcss {version}." + return False, (f"error: main is at fxcss {version}, which is not tagged, but this commit " + f"is not from a \"Prepare v{version}\" pull request, so it was not released. " + "Run the Release workflow by hand to release it, or set __version__ " + "back to the latest release.") + + +def bump_pins(text, version): + """Move every fxcss==X.Y.Z pin older than version to version.""" + target = parse(version) + count = 0 + + def replace(match): + nonlocal count + if parse(match[2]) < target: + count += 1 + return match[1] + version + return match[0] + + return PIN.sub(replace, text), count + + +def git(*args): + return subprocess.run(["git", *args], cwd=ROOT, check=True, text=True, + capture_output=True).stdout + + +def output(**values): + path = os.environ.get("GITHUB_OUTPUT") + if path: + with open(path, "a", encoding="utf-8") as handle: + for key, value in values.items(): + handle.write(f"{key}={value}\n") + + +def main(argv): + command, *args = argv or [""] + if command == "check-pr" and len(args) == 1: + base = read_version(git("show", f"{args[0]}:{VERSION_FILE}")) + head = read_version((ROOT / VERSION_FILE).read_text(encoding="utf-8")) + problems = check_pr(base, head, os.environ.get("PR_TITLE", ""), + git("tag", "--list", "v*").split()) + for problem in problems: + print(f"::error title=Release version::{problem}") + if not problems: + print(f"fxcss {head}: " + ("no version change." if base == head + else f"release PR from {base}.")) + return 1 if problems else 0 + if command == "plan" and not args: + version = read_version((ROOT / VERSION_FILE).read_text(encoding="utf-8")) + release, message = plan(version, git("tag", "--list", "v*").split(), + os.environ.get("EVENT_NAME", ""), os.environ.get("PR_TITLE", "")) + failed = message.startswith("error: ") + print(f"::error title=Release::{message[7:]}" if failed else + f"::notice title=Release::{message}") + output(version=version, release="yes" if release else "no") + return 1 if failed else 0 + if command == "bump-pins" and len(args) == 2: + version, path = args + parse(version) + text = Path(path).read_text(encoding="utf-8") + text, count = bump_pins(text, version) + if count: + Path(path).write_text(text, encoding="utf-8") + print(f"Moved {count} pin(s) in {path} to fxcss {version}.") + output(changed="yes" if count else "no") + return 0 + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_release_orchestration.py b/tests/test_release_orchestration.py index 28fa06e..a77d0ec 100644 --- a/tests/test_release_orchestration.py +++ b/tests/test_release_orchestration.py @@ -1,5 +1,6 @@ """Offline regression tests for the actual release workflow scripts and gates.""" +import importlib.util import json import os from pathlib import Path @@ -14,7 +15,10 @@ from tests.test_workflows import condition, script, step -WORKFLOWS = Path(__file__).resolve().parents[1] / '.github/workflows' +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / '.github/workflows' +# Records each gh call as a JSON argument list, one per line. +GH_STUB = 'gh() { "$PYTHON" -c \'import json,sys; print(json.dumps(sys.argv[1:]))\' "$@"; }\n' def job(text, name): @@ -22,6 +26,34 @@ def job(text, name): text, re.M | re.S)[1] +def steps(text): + return re.findall(r'^ - name: (.+)$', text, re.M) + + +def one_line_run(text, name): + return re.search(r'^ run: (.+)$', step(text, name), re.M)[1] + + +def load_release_rules(): + spec = importlib.util.spec_from_file_location('fxcss_release_rules', + ROOT / 'scripts/release.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +release = load_release_rules() + + +def isolated_git_env(root): + """Git that ignores the developer's own config, such as commit signing.""" + config = Path(root) / 'gitconfig' + config.write_text('[user]\n\tname = fxcss tests\n\temail = tests@example.invalid\n' + '[init]\n\tdefaultBranch = main\n' + '[commit]\n\tgpgsign = false\n[tag]\n\tgpgsign = false\n') + return dict(GIT_CONFIG_GLOBAL=config.as_posix(), GIT_CONFIG_NOSYSTEM='1') + + @unittest.skipUnless(shutil.which('bash'), 'workflow scripts require bash') class ReleaseOrchestrationTests(unittest.TestCase): def setUp(self): @@ -99,33 +131,25 @@ def test_installability_retry_immediate_delayed_and_exhausted(self): def test_website_dispatch_events_and_target(self): refresh = job(self.website, 'refresh') - guard = re.search(r'^ if: >-\n(.*?)(?=^ \S)', refresh, re.M | re.S)[1] - guard = ' '.join(line.strip() for line in guard.splitlines()) + guard = re.search(r'^ if: (.+)$', refresh, re.M)[1] block = step(self.website, 'Start Refresh docs on the website branch') command = re.search(r'^ run: (.+)$', block, re.M)[1] # Run the extracted command only when the extracted GitHub guard allows # it. The gh stub records arguments, including the target branch. - stub = 'gh() { "$PYTHON" -c \'import json,sys; print(json.dumps(sys.argv[1:]))\' "$@"; }\n' for repo in ('AdamXweb/fxcss', 'someone/fxcss'): - for event, conclusion in (('push', ''), ('workflow_run', 'success'), - ('workflow_run', 'failure'), ('workflow_run', 'cancelled'), - ('workflow_run', 'skipped'), ('workflow_run', 'timed_out')): - with self.subTest(repo=repo, event=event, conclusion=conclusion): - eligible = condition(guard, {'github.repository': repo, - 'github.event_name': event, - 'github.event.workflow_run.conclusion': conclusion}) - expected = repo == 'AdamXweb/fxcss' and ( - event == 'push' or conclusion == 'success') - self.assertEqual(eligible, expected) - result = self.run_script(stub + (command if eligible else ':'), - GITHUB_REPOSITORY=repo) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(json.loads(result.stdout) if result.stdout else [], - ['workflow', 'run', 'refresh-docs.yml', '--repo', repo, - '--ref', 'website'] if expected else []) + with self.subTest(repo=repo): + eligible = condition(guard, {'github.repository': repo}) + self.assertEqual(eligible, repo == 'AdamXweb/fxcss') + result = self.run_script(GH_STUB + (command if eligible else ':'), + GITHUB_REPOSITORY=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout) if result.stdout else [], + ['workflow', 'run', 'refresh-docs.yml', '--repo', repo, + '--ref', 'website'] if eligible else []) + # Publish starts the refresh after a release, so a workflow_run trigger + # here would refresh twice. triggers = self.website.split('\non:\n', 1)[1].split('\npermissions:', 1)[0] - self.assertIn('workflows: [Publish]', triggers) - self.assertIn('types: [completed]', triggers) + self.assertNotIn('workflow_run', triggers) self.assertIn('branches: [main]', triggers) self.assertIn('paths: [README.md]', triggers) self.assertIn('GH_TOKEN: ${{ github.token }}', block) @@ -165,6 +189,328 @@ def test_publication_requires_ci_and_checks_of_the_uploaded_distribution(self): self.assertIn('workflow_call:', ci) self.assertRegex(ci, r'compatibility:\n\s+description: [^\n]+\n\s+type: boolean\n\s+default: false') + def test_build_records_the_version_the_follow_up_reads(self): + build = job(self.publish, 'build') + self.assertIn('version: ${{ steps.version.outputs.version }}', build) + self.assertLess(build.index('Validate release version'), build.index('Record the version')) + self.assertIn('id: version', step(self.publish, 'Record the version')) + output = self.root / 'output' + output.write_text('') + result = self.run_script(one_line_run(self.publish, 'Record the version'), + GITHUB_OUTPUT=output.as_posix()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), 'version=9.8.7\n') + + def test_follow_up_waits_for_pypi_and_runs_each_step_after_a_failure(self): + follow = job(self.publish, 'follow-up') + needs = re.search(r'^ needs: \[([^\]]+)\]', follow, re.M)[1] + self.assertEqual({name.strip() for name in needs.split(',')}, {'build', 'publish'}) + self.assertNotRegex(follow, r'(?m)^ if:') # only after the gate succeeds + self.assertIn('contents: write', follow) + self.assertIn('actions: write', follow) + self.assertRegex(follow, r'actions/checkout@[^\n]+\n with:\n ref: main') + self.assertEqual(steps(follow), ['Move the README pins', 'Refresh the website', + 'Create the GitHub release']) + self.assertNotIn('if:', step(self.publish, 'Move the README pins')) + for name in ('Refresh the website', 'Create the GitHub release'): + self.assertIn('if: ${{ !cancelled() }}', step(self.publish, name)) + for name in ('Move the README pins', 'Create the GitHub release'): + self.assertIn('VERSION: ${{ needs.build.outputs.version }}', step(self.publish, name)) + result = self.run_script(GH_STUB + one_line_run(self.publish, 'Refresh the website'), + GITHUB_REPOSITORY='AdamXweb/fxcss') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), ['workflow', 'run', 'refresh-docs.yml', + '--repo', 'AdamXweb/fxcss', '--ref', 'website']) + + def test_follow_up_creates_the_release_only_when_it_is_missing(self): + stub = r''' + gh() { + printf '%s\n' "$*" >> "$RUNNER_TEMP/gh.log" + [ "$1 $2" != "release view" ] || [ -n "$RELEASE_EXISTS" ] + } + ''' + body = stub + script(self.publish, 'Create the GitHub release') + log = self.root / 'gh.log' + for exists in ('', '1'): + with self.subTest(exists=bool(exists)): + log.unlink(missing_ok=True) + result = self.run_script(body, VERSION='9.8.7', RELEASE_EXISTS=exists, + GITHUB_REPOSITORY='AdamXweb/fxcss', GITHUB_SHA='abc123') + self.assertEqual(result.returncode, 0, result.stderr) + calls = log.read_text().splitlines() + self.assertEqual(calls[0], 'release view v9.8.7 --repo AdamXweb/fxcss') + self.assertEqual(calls[1:], [] if exists else [ + 'release create v9.8.7 --repo AdamXweb/fxcss --target abc123 ' + '--title v9.8.7 --generate-notes']) + + @unittest.skipUnless(shutil.which('git'), 'the pin update pushes with git') + def test_follow_up_pushes_the_pin_update_after_main_moves(self): + env = isolated_git_env(self.root) + + def git(*args, cwd): + return subprocess.run(['git', *args], cwd=cwd, env=dict(os.environ, **env), + check=True, capture_output=True, text=True).stdout + + origin, seed, work = self.root / 'origin.git', self.root / 'seed', self.root / 'work' + git('init', '-q', '--bare', origin.as_posix(), cwd=self.root) + git('clone', '-q', origin.as_posix(), seed.as_posix(), cwd=self.root) + (seed / 'scripts').mkdir() + shutil.copy(ROOT / 'scripts/release.py', seed / 'scripts/release.py') + (seed / 'README.md').write_text('pip install "fxcss[images]==9.8.6"\n') + git('add', '.', cwd=seed) + git('commit', '-q', '-m', 'Start', cwd=seed) + git('push', '-q', 'origin', 'HEAD:main', cwd=seed) + git('clone', '-q', origin.as_posix(), work.as_posix(), cwd=self.root) + # main moves after the job's checkout, so the first push is rejected. + (seed / 'other.txt').write_text('a concurrent change') + git('add', '.', cwd=seed) + git('commit', '-q', '-m', 'Concurrent change', cwd=seed) + git('push', '-q', 'origin', 'HEAD:main', cwd=seed) + + body = 'python3() { "$PYTHON" "$@"; }\n' + script(self.publish, 'Move the README pins') + run = lambda: subprocess.run([shutil.which('bash'), '-c', body], cwd=work, + env=dict(os.environ, PYTHON=sys.executable, + VERSION='9.8.7', **env), + capture_output=True, text=True, timeout=60) + result = run() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('::notice title=README pins::Moved to fxcss 9.8.7', result.stdout) + log = git('log', '--format=%s', 'main', cwd=origin).splitlines() + self.assertEqual(log, ['Update the README pins to fxcss 9.8.7', 'Concurrent change', 'Start']) + self.assertEqual(git('show', 'main:README.md', cwd=origin), + 'pip install "fxcss[images]==9.8.7"\n') + # Already current: nothing to commit or push. + result = run() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(len(git('log', '--format=%s', 'main', cwd=origin).splitlines()), 3) + + def test_release_runs_on_main_for_version_changes_and_by_hand(self): + text = (WORKFLOWS / 'release.yml').read_text() + triggers = text.split('\non:\n', 1)[1].split('\npermissions:', 1)[0] + self.assertIn('push:\n branches: [main]\n paths: [fxcss/__init__.py]', triggers) + self.assertIn('workflow_dispatch:', triggers) + self.assertIn('permissions:\n contents: read', text) + tag = job(text, 'tag') + guard = re.search(r'^ if: (.+)$', tag, re.M)[1] + for repo, ref, expected in (('AdamXweb/fxcss', 'refs/heads/main', True), + ('AdamXweb/fxcss', 'refs/heads/feature', False), + ('someone/fxcss', 'refs/heads/main', False)): + with self.subTest(repo=repo, ref=ref): + self.assertEqual(condition(guard, {'github.repository': repo, + 'github.ref': ref}), expected) + for permission in ('contents: write', 'actions: write', 'pull-requests: read'): + self.assertIn(permission, tag) + self.assertIn('fetch-depth: 0', tag) # the plan reads every tag + self.assertEqual(steps(tag), ['Find the release pull request', 'Decide whether to release', + 'Tag the release', 'Start Publish on the tag']) + self.assertIn("if: github.event_name == 'push'", step(text, 'Find the release pull request')) + for name in ('Tag the release', 'Start Publish on the tag'): + self.assertIn("if: steps.plan.outputs.release == 'yes'", step(text, name)) + # The title reaches the script through the environment, never the shell text. + plan = step(text, 'Decide whether to release') + self.assertIn('PR_TITLE: ${{ steps.pr.outputs.title }}', plan) + self.assertIn('EVENT_NAME: ${{ github.event_name }}', plan) + self.assertEqual(one_line_run(text, 'Decide whether to release'), + 'python3 scripts/release.py plan') + + def test_release_finds_the_title_tags_and_starts_publish(self): + text = (WORKFLOWS / 'release.yml').read_text() + output = self.root / 'output' + stub = 'gh() { printf "%s\\n" "$*" >> "$RUNNER_TEMP/gh.log"; printf "%s\\n" "$STUB_TITLE"; }\n' + for title in ('Prepare v9.8.7 (#99)', '', 'Say "hi" $(exit 3) `exit 4`'): + with self.subTest(title=title): + output.write_text('') + (self.root / 'gh.log').unlink(missing_ok=True) + result = self.run_script(stub + script(text, 'Find the release pull request'), + STUB_TITLE=title, GITHUB_OUTPUT=output.as_posix(), + GITHUB_REPOSITORY='AdamXweb/fxcss', GITHUB_SHA='abc123') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), f'title={title}\n') + self.assertEqual((self.root / 'gh.log').read_text(), + 'api repos/AdamXweb/fxcss/commits/abc123/pulls --jq ' + '[.[] | select(.merged_at != null)][0].title // ""\n') + result = self.run_script(GH_STUB + script(text, 'Start Publish on the tag'), + VERSION='9.8.7', GITHUB_REPOSITORY='AdamXweb/fxcss') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout.splitlines()[0]), + ['workflow', 'run', 'publish.yml', '--repo', 'AdamXweb/fxcss', + '--ref', 'v9.8.7']) + + @unittest.skipUnless(shutil.which('git'), 'tagging uses git') + def test_release_pushes_the_tag_for_the_commit(self): + text = (WORKFLOWS / 'release.yml').read_text() + env = isolated_git_env(self.root) + + def git(*args, cwd): + return subprocess.run(['git', *args], cwd=cwd, env=dict(os.environ, **env), + check=True, capture_output=True, text=True).stdout + + origin, work = self.root / 'origin.git', self.root / 'work' + git('init', '-q', '--bare', origin.as_posix(), cwd=self.root) + git('clone', '-q', origin.as_posix(), work.as_posix(), cwd=self.root) + for message in ('Prepare v9.8.7', 'Later change'): + git('commit', '-q', '--allow-empty', '-m', message, cwd=work) + git('push', '-q', 'origin', 'HEAD:main', cwd=work) + released = git('rev-parse', 'HEAD~1', cwd=work).strip() + result = subprocess.run([shutil.which('bash'), '-c', script(text, 'Tag the release')], + cwd=work, env=dict(os.environ, VERSION='9.8.7', + GITHUB_SHA=released, **env), + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(git('rev-parse', 'v9.8.7^{commit}', cwd=origin).strip(), released) + + def test_release_version_check_runs_on_retitles_with_the_base_and_title(self): + text = (WORKFLOWS / 'release-version.yml').read_text() + triggers = text.split('\non:\n', 1)[1].split('\npermissions:', 1)[0] + self.assertIn('types: [opened, edited, synchronize, reopened]', triggers) + self.assertIn('permissions:\n contents: read', text) + check = step(text, 'Check the version change') + self.assertIn('BASE_SHA: ${{ github.event.pull_request.base.sha }}', check) + self.assertIn('PR_TITLE: ${{ github.event.pull_request.title }}', check) + self.assertEqual(one_line_run(text, 'Check the version change'), + 'python3 scripts/release.py check-pr "$BASE_SHA"') + self.assertIn('fetch-depth: 0', text) + + +class ReleaseRuleTests(unittest.TestCase): + TAGS = ['v0.9.0', 'v0.24.0', 'v0.25.0', 'not-a-version', 'v1.0'] + + def test_only_a_release_pull_request_may_change_the_version(self): + self.assertEqual(release.check_pr('0.25.0', '0.25.0', 'Add a thing', self.TAGS), []) + for title in ('Prepare v0.26.0', 'prepare v0.26.0', + 'Fix Firefox sidebar CI and prepare v0.26.0'): + with self.subTest(title=title): + self.assertEqual(release.check_pr('0.25.0', '0.26.0', title, self.TAGS), []) + for title in ('Preserve theme customizations', 'Prepare v0.26.1', 'Prepare v0.26.0.1', + 'Prepare v0.26.01', 'Prepare 0.26.0', ''): + with self.subTest(title=title): + problems = release.check_pr('0.25.0', '0.26.0', title, self.TAGS) + self.assertEqual(len(problems), 1, problems) + self.assertIn('title it "Prepare v0.26.0"', problems[0]) + downgrade = release.check_pr('0.25.0', '0.24.1', 'Prepare v0.24.1', self.TAGS) + self.assertEqual(len(downgrade), 1, downgrade) + self.assertIn('a release must increase it', downgrade[0]) + self.assertEqual(release.check_pr('0.24.0', '0.25.0', 'Prepare v0.25.0', self.TAGS), + ['v0.25.0 is already tagged; choose the next version.']) + + def test_plan_releases_new_versions_from_a_release_pull_request_or_by_hand(self): + for version, event, title, expected, message in ( + ('0.25.0', 'push', 'Prepare v0.25.0 (#82)', False, 'already tagged'), + ('0.24.1', 'workflow_dispatch', '', False, 'error: '), + ('0.26.0', 'push', 'Prepare v0.26.0 (#97)', True, 'Releasing fxcss 0.26.0.'), + ('0.26.0', 'push', 'Preserve theme customizations (#91)', False, 'error: '), + ('0.26.0', 'push', '', False, 'error: '), + ('0.26.0', 'workflow_dispatch', '', True, 'Releasing fxcss 0.26.0.'), + ('0.10.0', 'workflow_dispatch', '', False, 'error: ')): + with self.subTest(version=version, event=event, title=title): + result, text = release.plan(version, self.TAGS, event, title) + self.assertEqual(result, expected) + self.assertIn(message, text) + if message != 'error: ': + self.assertFalse(text.startswith('error: '), text) + self.assertEqual(release.plan('0.1.0', [], 'push', 'Prepare v0.1.0'), + (True, 'Releasing fxcss 0.1.0.')) + + def test_versions_are_strict_and_compared_as_numbers(self): + self.assertEqual(release.read_version('"""x"""\n\n__version__ = "0.26.0"\n'), '0.26.0') + for text in ('__version__ = "0.26"\n', '__version__ = "0.26.0rc1"\n', 'version = 1\n'): + with self.subTest(text=text), self.assertRaises(ValueError): + release.read_version(text) + self.assertLess(release.parse('0.9.0'), release.parse('0.25.0')) + self.assertEqual(release.released(self.TAGS), ['0.9.0', '0.24.0', '0.25.0']) + + def test_pins_move_forward_only(self): + text = ('pip install "fxcss[images]==0.24.0"\n' + '- run: pip install fxcss==0.24.0\n' + 'pip install "fxcss[images,extra]==0.27.0"\n' + 'needs fxcss **0.26.0 or later**; fxcss==0.26.0 already\n') + moved, count = release.bump_pins(text, '0.26.0') + self.assertEqual(count, 2) + self.assertEqual(moved, text.replace('==0.24.0', '==0.26.0')) + self.assertEqual(release.bump_pins(moved, '0.26.0'), (moved, 0)) + + def test_a_release_moves_only_the_readme_pins(self): + readme = (ROOT / 'README.md').read_text(encoding='utf-8') + version = release.read_version((ROOT / 'fxcss/__init__.py').read_text(encoding='utf-8')) + pins = release.PIN.findall(readme) + self.assertTrue(pins, 'the README has no fxcss pins for a release to move') + for _, pinned in pins: + self.assertLessEqual(release.parse(pinned), release.parse(version)) + moved, count = release.bump_pins(readme, '99.0.0') + self.assertEqual(count, len(pins)) + self.assertEqual(release.PIN.sub('PIN', moved), release.PIN.sub('PIN', readme)) + + +@unittest.skipUnless(shutil.which('git'), 'the release rules read git') +class ReleaseCommandTests(unittest.TestCase): + """The commands the workflows run, against a real repository and its tags.""" + + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.env = isolated_git_env(self.root) + self.repo = self.root / 'repo' + (self.repo / 'scripts').mkdir(parents=True) + (self.repo / 'fxcss').mkdir() + shutil.copy(ROOT / 'scripts/release.py', self.repo / 'scripts/release.py') + self.set_version('0.25.0') + self.git('init', '-q') + self.git('add', '.') + self.git('commit', '-q', '-m', 'Prepare v0.25.0') + self.git('tag', 'v0.25.0') + self.base = self.git('rev-parse', 'HEAD').strip() + + def git(self, *args): + return subprocess.run(['git', *args], cwd=self.repo, env=dict(os.environ, **self.env), + check=True, capture_output=True, text=True).stdout + + def set_version(self, version): + (self.repo / 'fxcss/__init__.py').write_text(f'"""fxcss."""\n\n__version__ = "{version}"\n') + + def run_release(self, *args, **env): + output = self.root / 'output' + output.write_text('') + result = subprocess.run([sys.executable, 'scripts/release.py', *args], cwd=self.repo, + env=dict(os.environ, GITHUB_OUTPUT=output.as_posix(), + **self.env, **env), + capture_output=True, text=True, timeout=30) + return result, output.read_text() + + def test_check_pr_compares_the_base_with_the_checkout(self): + result, _ = self.run_release('check-pr', self.base, PR_TITLE='Add a thing') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('fxcss 0.25.0: no version change.', result.stdout) + self.set_version('0.26.0') + result, _ = self.run_release('check-pr', self.base, PR_TITLE='Add a thing') + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn('::error title=Release version::This pull request changes the version ' + 'from 0.25.0 to 0.26.0', result.stdout) + result, _ = self.run_release('check-pr', self.base, PR_TITLE='Prepare v0.26.0') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('fxcss 0.26.0: release PR from 0.25.0.', result.stdout) + + def test_plan_writes_the_outputs_the_release_workflow_reads(self): + result, output = self.run_release('plan', EVENT_NAME='push', PR_TITLE='') + self.assertEqual((result.returncode, output), (0, 'version=0.25.0\nrelease=no\n')) + self.assertIn('::notice title=Release::fxcss 0.25.0 is already tagged', result.stdout) + self.set_version('0.26.0') + result, output = self.run_release('plan', EVENT_NAME='push', PR_TITLE='Prepare v0.26.0 (#97)') + self.assertEqual((result.returncode, output), (0, 'version=0.26.0\nrelease=yes\n')) + result, output = self.run_release('plan', EVENT_NAME='push', PR_TITLE='Add a thing (#91)') + self.assertEqual((result.returncode, output), (1, 'version=0.26.0\nrelease=no\n')) + self.assertIn('::error title=Release::main is at fxcss 0.26.0, which is not tagged', + result.stdout) + + def test_bump_pins_reports_whether_it_changed_the_file(self): + readme = self.repo / 'README.md' + readme.write_text('pip install "fxcss[images]==0.24.0"\n') + result, output = self.run_release('bump-pins', '0.25.0', 'README.md') + self.assertEqual((result.returncode, output), (0, 'changed=yes\n'), result.stderr) + self.assertEqual(readme.read_text(), 'pip install "fxcss[images]==0.25.0"\n') + result, output = self.run_release('bump-pins', '0.25.0', 'README.md') + self.assertEqual((result.returncode, output), (0, 'changed=no\n'), result.stderr) + if __name__ == '__main__': unittest.main()