diff --git a/CHANGELOG.md b/CHANGELOG.md index d98be01..9bb80c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and versions are tracked in the repo-root `VERSION` file. - Continue compatibility hardening and adoption work for the next release. +## [0.5.0] - 2026-10-03 + ### Added - Publish versioned, comparative CLI benchmark reports with lifecycle and @@ -391,7 +393,8 @@ the API stability policy and migration guide before upgrading from `0.3.x`. - Pinned the build backend to metadata compatible with the bundled publication action and made license-file validation portable across setuptools versions. -[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.4.3...HEAD +[Unreleased]: https://github.com/basefoundry/base-cli/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/basefoundry/base-cli/compare/v0.4.3...v0.5.0 [0.4.3]: https://github.com/basefoundry/base-cli/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/basefoundry/base-cli/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/basefoundry/base-cli/compare/v0.4.0...v0.4.1 diff --git a/README.md b/README.md index 20c13dd..6190f74 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ | --- | --- | --- | --- | | `0.4.3` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.3](https://github.com/basefoundry/base-cli/releases/tag/v0.4.3) | +Source release candidate: 0.5.0. Publication is pending the [release checklist](docs/release-0.5.0-checklist.md); PyPI remains the authority for available versions. + `base-cli` is the production lifecycle layer for Click and Typer Python CLIs. It standardizes context, logging, configuration, cleanup, and machine-readable contracts while leaving command and product policy in the consumer application. diff --git a/VERSION b/VERSION index 17b2ccd..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.3 +0.5.0 diff --git a/docs/migration-0.5.md b/docs/migration-0.5.md new file mode 100644 index 0000000..f07d22b --- /dev/null +++ b/docs/migration-0.5.md @@ -0,0 +1,33 @@ +# Migrating from 0.4.x to 0.5.0 + +0.5.0 is a pre-1.0 minor release. It retains the Click/Typer lifecycle APIs while +strengthening configuration, output, logging, retention, and release contracts. +The published 0.4.3 tag and distributions remain immutable. + +- Convenience-profile project configuration now verifies POSIX ownership and + permissions, refuses symlink/reparse paths, and stops discovery at `.git`, a + filesystem boundary, or the configured ancestor limit. Repair permissions or + set `verify_discovered_config=False` explicitly for a knowingly shared + workspace; see [local configuration](local-config.md). +- YAML inputs are bounded before alias construction. Split unusually large + configuration files and remove recursive or excessive alias graphs. +- JSON mode captures descriptor and inherited child stdout. Wait for children + and flush native stdio before returning. Output over 8 MiB produces an error; + a detached child produces `capture_incomplete` with the partial captured + output; use NDJSON for larger streams. See [JSON contracts](json-contracts.md). +- Consumer logging handlers and configured levels survive CLI cleanup. Foreign + handlers and parent routing are preserved while `--debug` and `--quiet` still + control the Base-owned stream. An explicitly configured host logger level may + filter persistent DEBUG messages; a foreign handler without a level does not. + Configure the host logger at DEBUG when those records are required. See + [integrations](integrations.md). +- Native Windows enforces bundle retention through pinned directory handles; + contended maintenance passes skip without blocking command execution. +- Repeated source paths and human log timestamps are cached; log-sidecar I/O + errors use logging's error path without aborting commands. +- Click 8.5 is supported within the declared Click window. Typer consumers must + use the documented compatible Click/Typer pairs. + +Review the dated [changelog](https://github.com/basefoundry/base-cli/blob/main/CHANGELOG.md) and the [platform boundary](platform-support.md) +when upgrading. Test the installed wheel in a clean environment, including your +JSON consumers, config permissions, and host logging integration. diff --git a/docs/release-0.5.0-checklist.md b/docs/release-0.5.0-checklist.md new file mode 100644 index 0000000..e70da33 --- /dev/null +++ b/docs/release-0.5.0-checklist.md @@ -0,0 +1,26 @@ +# 0.5.0 release candidate checklist + +This is release preparation. A dated changelog section does not mean the version +has been tagged or published. The proposed date must be refreshed if publication +happens on another day. Issue #307 remains open until publication evidence exists. + +1. Merge the reviewed 0.5.0 issue train and all remaining 0.5.0 PRs. Refresh the + release PR against their final integrated commit and include their changelog + entries in 0.5.0 before approval. +2. Obtain independent approval and passing required checks on the exact release + head. Keep the repository and environment approval rules in place. +3. Validate the 0.4.x migration boundary in [migration guidance](migration-0.5.md), + then run the full local gate and hosted platform, dependency, consumer, + benchmark, package, and provenance checks. +4. Rehearse the reviewed artifact through the protected TestPyPI environment. + Install exactly `base-cli==0.5.0` from a new environment and run lifecycle/JSON + smoke checks; retain the artifact digest and workflow URL. +5. After the release PR is merged, create annotated `v0.5.0` on the independently + approved protected-main commit. Never alter `v0.4.3` or its distributions. +6. Approve the protected production environment. Publish the exact reviewed + wheel/sdist, then verify matching SHA256SUMS, SPDX SBOM, provenance and SBOM + attestations, RELEASE-BOM-ROW, tag target, and GitHub release assets. +7. Verify a clean installation of exactly `base-cli==0.5.0` from PyPI. Record the + immutable release URL and evidence on #307, then close it. + +The operational commands and recovery rules remain in [Releasing](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index dcff6d8..31894b7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -204,3 +204,11 @@ rerun the workflow before creating a tag. If TestPyPI succeeds but a production publish fails, inspect the workflow logs and rerun the same approved tag only after confirming that neither artifact nor metadata needs correction. A version that was published successfully must be incremented for the next release. + +## 0.5.0 preparation + +Use the [0.5.0 checklist](release-0.5.0-checklist.md) and +[0.4.x migration notes](migration-0.5.md). The changelog validator permits the +newest section matching `VERSION` to precede its tag while preparing a release +PR; all earlier published sections remain checked against their tags. Once the +current tag exists, its section is immutable too. diff --git a/mkdocs.yml b/mkdocs.yml index 6f5e7b6..7116d05 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Dependency support: dependency-support.md - Optional output dependencies: optional-output-dependencies.md - Migration guide: migrations.md + - Migrating to 0.5.0: migration-0.5.md - Click migration: migration-click.md - Typer migration: migration-typer.md - Cement migration: migration-cement.md @@ -66,3 +67,4 @@ nav: - Runtime threat model: security-threat-model.md - Security release review: security-review.md - Releasing base-cli: releasing.md + - 0.5.0 release checklist: release-0.5.0-checklist.md diff --git a/scripts/validate_changelog.py b/scripts/validate_changelog.py index ed95b5f..03b0530 100644 --- a/scripts/validate_changelog.py +++ b/scripts/validate_changelog.py @@ -129,6 +129,8 @@ def _validate_published_sections( """Ensure every released section remains identical to its version tag.""" errors: list[str] = [] + version_file = path.parent / "VERSION" + candidate = version_file.read_text(encoding="utf-8").strip() if version_file.is_file() else None for version in versions: if version == "Unreleased": continue @@ -140,13 +142,30 @@ def _validate_published_sections( capture_output=True, text=True, ) - except (OSError, subprocess.CalledProcessError) as exc: + except subprocess.CalledProcessError as exc: + # A release PR necessarily precedes its protected-main tag. Only + # the newest section matching VERSION may be an untagged candidate; + # every earlier release remains bound to its immutable tag. + if version == candidate and len(versions) > 1 and version == versions[1]: + exists = subprocess.run( + ["git", "-C", str(path.parent), "rev-parse", "--verify", f"refs/tags/{tag}"], + capture_output=True, + text=True, + check=False, + ) + if exists.returncode == 1 or exists.returncode == 128: + continue detail = getattr(exc, "stderr", None) or str(exc) errors.append( f"cannot verify [{version}] against tag {tag}: {detail.strip()}; " "fetch the release tags before validating" ) continue + except OSError as exc: + errors.append( + f"cannot verify [{version}] against tag {tag}: {exc}; fetch the release tags before validating" + ) + continue tagged_lines = completed.stdout.splitlines() current_section = _section_text(lines, version) tagged_section = _section_text(tagged_lines, version) diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py index 27f15c9..708b8a1 100644 --- a/scripts/validate_docs.py +++ b/scripts/validate_docs.py @@ -33,12 +33,14 @@ "migration-click.md", "migration-typer.md", "migrations.md", + "migration-0.5.md", "output-contracts.md", "optional-output-dependencies.md", "performance.md", "testing.md", "platform-support.md", "releasing.md", + "release-0.5.0-checklist.md", "security-review.md", "security-threat-model.md", "schemas.md", diff --git a/tests/test_validate_changelog.py b/tests/test_validate_changelog.py index 7be2b23..7d61201 100644 --- a/tests/test_validate_changelog.py +++ b/tests/test_validate_changelog.py @@ -78,6 +78,19 @@ def test_rejects_edits_to_a_published_section(self) -> None: errors = validate_changelog.validate_changelog(path, verify_tags=True) self.assertIn("published changelog section [1.0.0] differs from tag v1.0.0", errors) + def test_current_untagged_release_candidate_can_be_prepared(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "CHANGELOG.md" + path.write_text(VALID_CHANGELOG, encoding="utf-8") + (path.parent / "VERSION").write_text("1.0.0\n") + missing = subprocess.CalledProcessError(128, "git", stderr="missing tag") + with mock.patch( + "scripts.validate_changelog.subprocess.run", + side_effect=[missing, subprocess.CompletedProcess("git", 128)], + ): + errors = validate_changelog.validate_changelog(path, verify_tags=True) + self.assertEqual(errors, []) + def test_reports_unavailable_release_tags(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "CHANGELOG.md" @@ -91,6 +104,17 @@ def test_reports_unavailable_release_tags(self) -> None: errors = validate_changelog.validate_changelog(path, verify_tags=True) self.assertTrue(any("fetch the release tags" in error for error in errors)) + def test_reports_missing_git_without_retrying_with_an_uncaught_oserror(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "CHANGELOG.md" + path.write_text(VALID_CHANGELOG, encoding="utf-8") + with mock.patch( + "scripts.validate_changelog.subprocess.run", + side_effect=OSError("git is not installed"), + ): + errors = validate_changelog.validate_changelog(path, verify_tags=True) + self.assertTrue(any("git is not installed" in error for error in errors)) + if __name__ == "__main__": unittest.main() diff --git a/tests/validate.sh b/tests/validate.sh index 54c757b..82cf60e 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -60,6 +60,8 @@ for file in "${required_files[@]}"; do done version="$(head -n 1 VERSION | tr -d '\r')" +published_version="$(git tag --list 'v[0-9]*' --sort=-version:refname | sed -n '1p' | sed 's/^v//')" +published_version="${published_version:-$version}" readme_head="$(sed -n '1,18p' README.md | tr -d '\r')" if ! printf '%s\n' "$readme_head" | grep -F "[![Tests](https://img.shields.io/github/actions/workflow/status/basefoundry/base-cli/tests.yml?branch=main&label=tests)](https://github.com/basefoundry/base-cli/actions/workflows/tests.yml)" >/dev/null; then printf 'README.md is missing the main-branch tests health badge.\n' >&2 @@ -77,8 +79,8 @@ if ! printf '%s\n' "$readme_head" | grep -F "[![Python](https://img.shields.io/p printf 'README.md is missing the supported Python versions badge.\n' >&2 exit 1 fi -if ! printf '%s\n' "$readme_head" | grep -F "| \`$version\` | [Apache-2.0](LICENSE) | \`python -m pip install base-cli\` | [v$version](https://github.com/basefoundry/base-cli/releases/tag/v$version) |" >/dev/null; then - printf 'README.md release strip does not match VERSION (%s), license, install command, and release link.\n' "$version" >&2 +if ! printf '%s\n' "$readme_head" | grep -F "| \`$published_version\` | [Apache-2.0](LICENSE) | \`python -m pip install base-cli\` | [v$published_version](https://github.com/basefoundry/base-cli/releases/tag/v$published_version) |" >/dev/null; then + printf 'README.md release strip does not match the latest published tag (%s), license, install command, and release link.\n' "$published_version" >&2 exit 1 fi