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
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
33 changes: 33 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
100 changes: 100 additions & 0 deletions packaging/test_release.py
Original file line number Diff line number Diff line change
@@ -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()
Loading