diff --git a/.github/workflows/release_prep.sh b/.github/workflows/release_prep.sh index 2a73dbf..7566672 100755 --- a/.github/workflows/release_prep.sh +++ b/.github/workflows/release_prep.sh @@ -52,8 +52,8 @@ if [[ "${CHANGELOG_VERSION}" != "${VERSION}" ]]; then die "Tag = '${TAG}' does not match version = '${CHANGELOG_VERSION}' in CHANGELOG.md." fi -# Instead of embed the version in MODULE.bazel, we expect it to be correct already. -# perl -pi -e "s/version = \"\d+\.\d+\.\d+\",/version = \"${TAG}\",/g" MODULE.bazel +# Reject an unprepared tag rather than publishing inconsistent version references. +python3 tools/prepare_release.py --check "${VERSION}" # Empty `BUILD.bazel` { diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c33d73c..59b9b76 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: name: Test publishing and CI policy language: system entry: python3 -m unittest discover -s tools -p '*_test.py' - files: ^(tools/|docs/assets/|release-site\.json$|\.github/workflows/) + files: ^(\.pre-commit/check_version\.sh$|tools/|docs/assets/|release-site\.json$|\.github/workflows/) pass_filenames: false - id: uncomment-bazelmod-includes name: uncomment-bazelmod-includes @@ -49,8 +49,9 @@ repos: - id: compare-versions name: compare-versions description: | - Require matching CHANGELOG.md and MODULE.bazel versions. After a release, - the first later commit must advance both beyond the latest release tag. + Require matching MODULE.bazel and CHANGELOG.md development versions, and a + released or release-prepared README example. After a release, the first later + commit must advance the development version beyond the latest release tag. language: system entry: .pre-commit/check_version.sh pass_filenames: False diff --git a/.pre-commit/check_version.sh b/.pre-commit/check_version.sh index 53455ad..d34d363 100755 --- a/.pre-commit/check_version.sh +++ b/.pre-commit/check_version.sh @@ -58,3 +58,17 @@ if [[ -n "${LATEST_RELEASE}" ]] && [[ "$(git rev-parse HEAD)" != "$(git rev-list -n1 "${LATEST_RELEASE}")" ]]; then die "Development version (${BAZELMOD_VERSION}) still matches latest release tag (${LATEST_RELEASE}), but HEAD contains later work. Bump MODULE.bazel and CHANGELOG.md." fi + +README_VERSION="$(sed -nE 's/.*bazel_dep\(name = "mboworks_bashtest", version = "([^"]+)".*/\1/p' README.md)" +if [[ ! "${README_VERSION}" =~ ^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$ ]]; then + die "README.md must contain one numeric bashtest dependency version." +fi +# Development keeps the latest released installation example. Release preparation +# advances it to the module version in the reviewed commit that will be tagged. +if [[ -n "${LATEST_RELEASE}" ]]; then + if [[ "${README_VERSION}" != "${LATEST_RELEASE}" && "${README_VERSION}" != "${BAZELMOD_VERSION}" ]]; then + die "README.md (${README_VERSION}) must match latest release (${LATEST_RELEASE}) or prepared release (${BAZELMOD_VERSION})." + fi +elif [[ "$(version_key "${README_VERSION}")" > "$(version_key "${BAZELMOD_VERSION}")" ]]; then + die "README.md (${README_VERSION}) is newer than MODULE.bazel (${BAZELMOD_VERSION})." +fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 509e6c9..f7818b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 0.7.1 + +* Derive consumer fixture versions from the module and automate the README + version update during release preparation. Test development/release version + consistency and post-release version bumps. + +* Fixed the tools test's path expectation when bashtest is an external module. +* Run the framework test suite as an external dependency in CI to catch + root-workspace assumptions before BCR publication. + # 0.7.0 * Automatically provision Bash and Unix utilities, including PCRE support, for diff --git a/MODULE.bazel b/MODULE.bazel index 2b05a8b..04f8bd8 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -17,7 +17,7 @@ module( name = "mboworks_bashtest", - version = "0.7.0", + version = "0.7.1", ) bazel_dep(name = "bazel_skylib", version = "1.9.2") diff --git a/README.md b/README.md index 8f03147..ed00228 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,12 @@ remains byte-for-byte, so CRLF and LF golden files are intentionally different. ### MODULE.bazel -Check [Releases](https://github.com/mboworks/bashtest/releases) for details. All that is needed is a `bazel_dep` instruction with the correct version. +Use the latest published version from +[Releases](https://github.com/mboworks/bashtest/releases) when consuming bashtest +through the Bazel Central Registry. ``` -bazel_dep(name = "mboworks_bashtest", version = "0.6.1") +bazel_dep(name = "mboworks_bashtest", version = "0.7.0") ``` Executable helpers can be declared with `tools = {"GENERATOR": "//tools:generator"}`. diff --git a/bashtest/tests/BUILD.bazel b/bashtest/tests/BUILD.bazel index 60a633a..da29498 100644 --- a/bashtest/tests/BUILD.bazel +++ b/bashtest/tests/BUILD.bazel @@ -93,6 +93,7 @@ bashtest( env = { "TARGET_DATA": "$(rlocationpath :target_data)", "TARGET_LOCATION": "$(location :target_data)", + "TARGET_ROOTPATH": "$(rootpath :target_data)", }, tools = { "HELPER": ":helper", diff --git a/bashtest/tests/external_consumer_test.sh b/bashtest/tests/external_consumer_test.sh index ba01b12..07c816b 100755 --- a/bashtest/tests/external_consumer_test.sh +++ b/bashtest/tests/external_consumer_test.sh @@ -18,6 +18,8 @@ # Run outside a Bazel test: this creates and builds a separate consumer module. set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# The override consumes this checkout, including unreleased development versions. +version="$(sed -nE 's/.*version = "([^"]+)".*/\1/p' "${repo}/MODULE.bazel" | head -n1)" root="$(mktemp -d)" cleanup() { # Windows cannot remove a directory while it is a process working directory. @@ -34,7 +36,7 @@ cd "${root}/consumer with spaces" cp "${repo}/.bazelversion" .bazelversion cat > MODULE.bazel < BUILD.bazel <<'EOF' @@ -61,7 +63,9 @@ test::consumer() { } test_runner EOF -bazel test --enable_runfiles=no --test_output=errors //:consumer_test +# Also run the framework's own suite as an external dependency, as BCR does. +# A standalone consumer test alone cannot catch root-workspace assumptions in it. +bazel test --enable_runfiles=no --test_output=errors //:consumer_test @framework//bashtest/tests/... bazel run --enable_runfiles=no //:consumer_test -- --keep-tmpdir=never # Exercise both documented update spellings with a workspace path containing spaces. diff --git a/bashtest/tests/tools_consumer_test.sh b/bashtest/tests/tools_consumer_test.sh index 76d104f..3379792 100755 --- a/bashtest/tests/tools_consumer_test.sh +++ b/bashtest/tests/tools_consumer_test.sh @@ -18,6 +18,8 @@ # Integration test: distinct default and test execution platforms, external module. set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# The override consumes this checkout, including unreleased development versions. +version="$(sed -nE 's/.*version = "([^"]+)".*/\1/p' "${repo}/MODULE.bazel" | head -n1)" root="$(mktemp -d)" cleanup() { bazel shutdown >/dev/null 2>&1 || true @@ -34,7 +36,7 @@ cp "${repo}/.bazelversion" .bazelversion cp "${repo}/bashtest/tests/tool_fixture.bzl" tool_fixture.bzl cat > MODULE.bazel <" -for tool in gh git gpg; do +for tool in gh git gpg python3; do command -v "${tool}" >/dev/null 2>&1 || die "Required tool '${tool}' is not installed." done @@ -40,6 +40,9 @@ CHANGELOG_VERSION="$(sed -rne 's,^# ([0-9]+([.][0-9]+)+.*)$,\1,p' CHANGELOG.md | [[ -z "$(git tag --list "${VERSION}")" ]] || die "Tag '${VERSION}' already exists." gh release view "${VERSION}" >/dev/null 2>&1 && die "Release '${VERSION}' already exists." +# All tracked release references must already be reviewed and committed. +python3 tools/prepare_release.py --check "${VERSION}" + if [[ "${DRY_RUN}" == true ]]; then echo "[dry-run] Would create and push signed tag '${VERSION}' at $(git rev-parse HEAD)." exit 0 diff --git a/tools/version_test.py b/tools/version_test.py new file mode 100644 index 0000000..5338820 --- /dev/null +++ b/tools/version_test.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Exercise the version gate against isolated repository histories.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import tarfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class VersionTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.git('init', '--quiet') + self.git('config', 'user.name', 'Version test') + self.git('config', 'user.email', 'version-test@example.invalid') + self.git('config', 'commit.gpgsign', 'false') + self.set_version('0.7.0') + self.commit() + + def git(self, *args): + return subprocess.run(['git', *args], cwd=self.root, check=True, + capture_output=True, text=True) + + def commit(self): + self.git('add', '.') + self.git('-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', + '--allow-empty', '-m', 'Fixture') + + def set_version(self, version): + (self.root / 'MODULE.bazel').write_text( + f'module(name = "mboworks_bashtest", version = "{version}")\n') + (self.root / 'CHANGELOG.md').write_text(f'# {version}\n\n# 0.1.0\n') + self.set_readme(version) + + def set_readme(self, version): + (self.root / 'README.md').write_text( + f'bazel_dep(name = "mboworks_bashtest", version = "{version}")\n') + + def prepare(self, version, check=False): + args = ['python3', str(ROOT / 'tools/prepare_release.py'), version] + if check: + args.append('--check') + return subprocess.run(args, cwd=self.root, capture_output=True, text=True) + + def check(self, error=None): + result = subprocess.run(['bash', str(ROOT / '.pre-commit/check_version.sh')], + cwd=self.root, capture_output=True, text=True) + if error is None: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn(error, result.stderr) + + def test_matching_versions_without_tags(self): + self.check() + + def test_changelog_must_match(self): + (self.root / 'CHANGELOG.md').write_text('# 0.6.1\n') + self.check('CHANGELOG.md') + + def test_missing_or_ambiguous_dependency_is_rejected(self): + path = self.root / 'README.md' + for content in ('No dependency example\n', path.read_text() * 2): + with self.subTest(content=content): + path.write_text(content) + self.check('README.md') + self.assertNotEqual(self.prepare('0.7.0').returncode, 0) + + def test_development_keeps_released_readme(self): + self.git('tag', '0.7.0') + self.set_version('0.7.1') + self.set_readme('0.7.0') + self.commit() + self.check() + self.set_readme('0.6.1') + self.check('README.md') + self.set_readme('0.7.2') + self.check('README.md') + + def test_release_preparation_and_next_development_cycle(self): + self.git('tag', '0.7.0') + self.set_version('0.7.1') + self.set_readme('0.7.0') + before = (self.root / 'README.md').read_text() + result = self.prepare('0.7.1', check=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('before tagging', result.stderr) + self.assertEqual((self.root / 'README.md').read_text(), before) + result = self.prepare('0.7.1') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('version = "0.7.1"', (self.root / 'README.md').read_text()) + self.assertEqual(self.prepare('0.7.1', check=True).returncode, 0) + self.assertEqual(self.prepare('0.7.1').returncode, 0) + self.check() + self.commit() + self.git('tag', '0.7.1') + self.check() + self.set_version('0.7.2') + self.set_readme('0.7.1') + self.commit() + self.check() + + def test_preparation_rejects_wrong_version_without_writing(self): + before = (self.root / 'README.md').read_text() + for version in ('0.7.1', '0.7.0-rc1'): + self.assertNotEqual(self.prepare(version).returncode, 0) + self.assertEqual((self.root / 'README.md').read_text(), before) + (self.root / 'CHANGELOG.md').write_text('# 0.6.1\n') + self.assertNotEqual(self.prepare('0.7.0').returncode, 0) + self.assertEqual((self.root / 'README.md').read_text(), before) + + def test_archive_rejects_unprepared_readme_and_packages_prepared_version(self): + for name in ('tools/prepare_release.py', 'tools/header.txt', + 'tools/release_notes.sh', '.github/release-notes.md.template'): + destination = self.root / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / name, destination) + self.set_version('0.7.1') + self.set_readme('0.7.0') + self.commit() + command = ['bash', str(ROOT / '.github/workflows/release_prep.sh')] + environment = dict(os.environ, GITHUB_REF_NAME='0.7.1') + result = subprocess.run(command, cwd=self.root, env=environment, + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('before tagging', result.stderr) + archive = self.root / 'bashtest-0.7.1.tar.gz' + self.assertFalse(archive.exists()) + self.assertEqual(self.prepare('0.7.1').returncode, 0) + self.commit() + result = subprocess.run(command, cwd=self.root, env=environment, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('version = "0.7.1"', result.stdout) + with tarfile.open(archive) as packaged: + for name in ('README.md', 'MODULE.bazel'): + content = packaged.extractfile('bashtest-0.7.1/' + name).read().decode() + self.assertIn('version = "0.7.1"', content) + self.assertEqual(packaged.extractfile('bashtest-0.7.1/VERSION').read(), b'0.7.1') + + def test_invalid_version_is_rejected(self): + self.set_version('0.7.0-rc1') + self.check('numeric X.Y.Z') + + def test_released_commit_then_next_development_commit(self): + self.git('tag', '0.7.0') + self.check() + self.commit() + self.check('HEAD contains later work') + self.set_version('0.7.1') + self.check() + + def test_latest_release_uses_numeric_order(self): + self.git('tag', '0.9.0') + self.git('tag', '0.10.0') + self.git('tag', 'unrelated-tag') + self.set_version('0.9.1') + self.check('older than latest release tag (0.10.0)') + self.set_version('0.10.1') + self.check() + + +if __name__ == '__main__': + unittest.main()