From b5619effe9a2bbca6d436c50d1524e19d015bf0f Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 5 Nov 2026 15:24:48 -0500 Subject: [PATCH] Let a release candidate update the Homebrew tap and the Scoop bucket The tap job was skipped for a tag with a suffix, so brew and scoop installed 0.1.2 while the command was at 1.0.0-rc2. A candidate now pushes the formula and the manifest like a release does; Homebrew and Scoop both order 1.0.0-rcN below 1.0.0, so the final release replaces the candidates. The release scripts gain tests, in check and in the workflow. --- .github/workflows/release.yml | 8 ++- CONTRIBUTING.md | 5 +- README.md | 7 ++- build.gradle | 33 +++++++++++ packaging/test_release.py | 100 ++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 packaging/test_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f785d14..e0a5d3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,10 @@ # Homebrew formula and Scoop manifest that install them, pushed to the tap # (DarkCollective/homebrew-relix) and the bucket (DarkCollective/scoop-relix). # -# A tag vX.Y.Z publishes X.Y.Z. A tag with a suffix (v1.2.0-rc1) publishes a pre-release -# and leaves the tap and the bucket alone. +# A tag vX.Y.Z publishes X.Y.Z. A tag with a suffix (v1.2.0-rc1) publishes a GitHub +# pre-release; the tap and the bucket follow it all the same, so `brew install relix` and +# `scoop install relix` give the newest release, a release candidate included. Both order +# 1.2.0-rc1 below 1.2.0, so the final release replaces its candidates as the install. # # Run from the Actions tab, it is a dry run: it builds and tests every archive, writes the # formula and the manifest and checks their checksums against the archives, and uploads @@ -127,6 +129,7 @@ jobs: pattern: archive-* path: dist merge-multiple: true + - run: python3 -m unittest discover -s packaging -p 'test_*.py' # Fails unless every archive the formula and the manifest name is in dist with the # checksum they give it. - run: python3 packaging/release.py "$VERSION" dist release-files @@ -168,7 +171,6 @@ jobs: tap: name: Update the Homebrew tap and the Scoop bucket needs: [version, publish] - if: needs.version.outputs.stable == 'true' runs-on: ubuntu-latest env: VERSION: ${{ needs.version.outputs.version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 939e03a..4aa1e1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -168,8 +168,9 @@ property `baseDir`). workflow runs again, attaches the archives and `checksums.txt` to the GitHub Release, and pushes the formula to `DarkCollective/homebrew-relix` and the manifest to `DarkCollective/scoop-relix`, with the `HOMEBREW_TAP_PAT` and `SCOOP_BUCKET_PAT` - secrets. A tag with a suffix (`v1.2.0-rc1`) makes a pre-release and leaves the tap and - the bucket alone. + secrets. A tag with a suffix (`v1.2.0-rc1`) makes a GitHub pre-release and updates the + tap and the bucket in the same way, so they carry the newest release candidate until + the final release replaces it (Homebrew and Scoop both order `1.2.0-rc1` below `1.2.0`). The formula and the manifest are written whole from `packaging/` on every release, so a change to either is made there, not in the tap or the bucket. diff --git a/README.md b/README.md index a59608d..ccecef7 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,10 @@ scoop bucket add relix https://github.com/DarkCollective/scoop-relix scoop install relix ``` -Homebrew and Scoop install the latest stable release; a release candidate leaves the tap -and the bucket alone. To try one, or to install without a package manager, download the -archive for your platform from the +Homebrew and Scoop install the newest release, a release candidate included: while 1.0.0 +is being prepared, `relix` is the newest `1.0.0-rcN`, and the final 1.0.0 replaces it +(both order a candidate below its release). To install without a package manager, +download the archive for your platform from the [releases](https://github.com/DarkCollective/relix-cli/releases), check it against `checksums.txt`, unpack it and put its `bin/` on your `PATH`. diff --git a/build.gradle b/build.gradle index 7508636..5762cd5 100644 --- a/build.gradle +++ b/build.gradle @@ -380,6 +380,39 @@ DIR="$(cd "$(dirname "$LINK")" && pwd)"''') } } +// The release scripts' own tests (packaging/test_release.py): the formula and the manifest +// written for a release candidate and a final release, and the rule that the tap and the +// bucket follow both. Skipped, with a warning, on a machine with no Python; the release +// workflow runs them too. +def packagingScriptTest = tasks.register('packagingScriptTest') { + description = 'Runs the release scripts\' tests.' + inputs.dir(layout.projectDirectory.dir('packaging')) + inputs.file(layout.projectDirectory.file('.github/workflows/release.yml')) + def dir = layout.projectDirectory.dir('packaging').asFile + def stamp = layout.buildDirectory.file('packagingScriptTest.stamp') + outputs.file(stamp) + doLast { + def python = ['python3', 'python'].find { + try { new ProcessBuilder(it, '--version').redirectErrorStream(true).start().waitFor() == 0 } + catch (IOException ignored) { false } + } + if (python == null) { + logger.warn('No Python on the PATH: the release scripts\' tests were not run.') + } else { + def process = new ProcessBuilder(python, '-m', 'unittest', 'discover', '-s', dir.path, '-p', 'test_*.py') + .redirectErrorStream(true).start() + def output = process.inputStream.text + if (process.waitFor() != 0) { + throw new GradleException("The release scripts' tests failed:\n${output}") + } + } + stamp.get().asFile.text = 'ok' + } +} +tasks.named('check') { + dependsOn packagingScriptTest +} + // The man pages and completion scripts, from the command's picocli model. def releaseFilesDir = layout.buildDirectory.dir('release-files') def releaseFiles = tasks.register('releaseFiles', JavaExec) { diff --git a/packaging/test_release.py b/packaging/test_release.py new file mode 100644 index 0000000..62026d5 --- /dev/null +++ b/packaging/test_release.py @@ -0,0 +1,100 @@ +"""Tests of release.py and of the release workflow's rule for the tap and the bucket. + +Run with: python3 -m unittest discover -s packaging -p 'test_*.py' +""" +import json +import pathlib +import re +import tempfile +import unittest + +import release + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WORKFLOW = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + +def archives(version: str, directory: pathlib.Path) -> None: + for platform in release.PLATFORMS: + (directory / release.archive_name(version, platform)).write_bytes(platform.encode()) + + +class ReleaseFiles(unittest.TestCase): + def render(self, version: str): + with tempfile.TemporaryDirectory() as tmp: + dist, out = pathlib.Path(tmp, "dist"), pathlib.Path(tmp, "out") + dist.mkdir() + archives(version, dist) + import sys + argv, sys.argv = sys.argv, ["release.py", version, str(dist), str(out)] + try: + release.main() + finally: + sys.argv = argv + return ((out / "relix.rb").read_text(encoding="utf-8"), + json.loads((out / "relix.json").read_text(encoding="utf-8")), + (out / "checksums.txt").read_text(encoding="utf-8")) + + def test_a_release_candidate_fills_the_formula_and_the_manifest(self): + formula, manifest, sums = self.render("1.0.0-rc3") + self.assertIn('version "1.0.0-rc3"', formula) + self.assertEqual("1.0.0-rc3", manifest["version"]) + self.assertEqual("relix-1.0.0-rc3-windows-x86_64.zip", + manifest["architecture"]["64bit"]["url"].rsplit("/", 1)[1]) + self.assertEqual("relix-1.0.0-rc3", manifest["architecture"]["64bit"]["extract_dir"]) + self.assertEqual(5, len(sums.splitlines())) + + def test_a_stable_release_fills_them_too(self): + formula, manifest, _ = self.render("1.0.0") + self.assertIn('version "1.0.0"', formula) + self.assertEqual("1.0.0", manifest["version"]) + + def test_the_formula_names_the_archives_release_py_checks(self): + formula, _, _ = self.render("1.0.0-rc3") + self.assertEqual( + {release.archive_name("1.0.0-rc3", p) for p in release.PLATFORMS if "windows" not in p}, + set(release.formula_archives(formula, "1.0.0-rc3"))) + + def test_a_missing_archive_fails(self): + with tempfile.TemporaryDirectory() as tmp: + import sys + argv, sys.argv = sys.argv, ["release.py", "1.0.0-rc3", tmp, tmp + "/out"] + try: + with self.assertRaises(SystemExit): + release.main() + finally: + sys.argv = argv + + def test_a_version_that_is_not_one_fails(self): + import sys + argv, sys.argv = sys.argv, ["release.py", "rc3", ".", "out"] + try: + with self.assertRaises(SystemExit): + release.main() + finally: + sys.argv = argv + + +class TapWorkflow(unittest.TestCase): + def job(self, name: str) -> str: + match = re.search(rf"^ {name}:\n(.*?)(?=^ \w[\w-]*:\n|\Z)", WORKFLOW, re.S | re.M) + self.assertIsNotNone(match, f"no job {name}") + return match.group(1) + + def test_the_tap_job_runs_for_a_release_candidate(self): + tap = self.job("tap") + self.assertNotIn("stable", tap, "the tap job must not be skipped for a pre-release") + self.assertNotRegex(tap, r"(?m)^ if:") + self.assertIn("publish", re.search(r"needs: (.*)", tap).group(1)) + + def test_both_package_managers_are_pushed(self): + tap = self.job("tap") + self.assertIn("homebrew-relix", tap) + self.assertIn("scoop-relix", tap) + + def test_a_candidate_is_still_a_github_pre_release(self): + self.assertIn("--prerelease", self.job("publish")) + + +if __name__ == "__main__": + unittest.main()