From 415830f8402294165f36a8c81784ca0e0ce226bf Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:13:42 -0700 Subject: [PATCH] Establish guarded Base Bash v2 release line --- .github/workflows/tests.yml | 10 +- AGENTS.md | 10 +- CHANGELOG.md | 11 +- CONTRIBUTING.md | 4 +- README.md | 13 ++ docs/release-process.md | 31 +++-- docs/versioning-policy.md | 116 ++++++++++++++++ scripts/release | 199 ++++++++++++++++++++++++++++ tests/bash-42-release-smoke.sh | 92 +++++++++++++ tests/fixtures/basectl-release-stub | 38 ++++++ tests/lint-warnings.sh | 4 + tests/release.bats | 177 +++++++++++++++++++++++++ tests/validate.sh | 16 ++- 13 files changed, 700 insertions(+), 21 deletions(-) create mode 100644 docs/versioning-policy.md create mode 100755 scripts/release create mode 100755 tests/bash-42-release-smoke.sh create mode 100755 tests/fixtures/basectl-release-stub create mode 100644 tests/release.bats diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 673a6a8..7b0a609 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,7 +65,7 @@ jobs: ! grep -F "syntax error" <<<"$output" bash-42-logging: - name: Logging smoke (Bash 4.2.53) + name: Compatibility smoke (Bash 4.2.53) runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -73,7 +73,7 @@ jobs: with: persist-credentials: false - - name: Run Bash 4.2 logging smoke + - name: Run Bash 4.2 compatibility smokes run: | docker run --rm \ --platform linux/amd64 \ @@ -89,4 +89,8 @@ jobs: --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ --workdir /workspace \ docker.io/library/bash@sha256:0931edd3941d0603cb3d5da1cb298cf3eb6a579e09e094c3e34e2d5e9df8cddc \ - bash tests/bash-42-logging-smoke.sh 4 2 53 + bash -c ' + set -e + bash tests/bash-42-logging-smoke.sh 4 2 53 + bash tests/bash-42-release-smoke.sh 4 2 53 + ' diff --git a/AGENTS.md b/AGENTS.md index 097c0a3..dcafb02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,12 @@ Read [docs/release-process.md](docs/release-process.md) before doing release work. Ordinary pull requests must leave `VERSION` unchanged; a release-prep pull request owns the version, README release row, and changelog transition. -The repository release contract is declared in `base_manifest.yaml`. Use the -guarded `basectl release check|plan|notes|publish` commands after the release -preparation pull request is merged, and complete the Homebrew and Base -downstream handoffs documented in the release process. +The repository release contract is declared in `base_manifest.yaml`, and the +active release-line policy is documented in `docs/versioning-policy.md`. Use +the repository-owned `scripts/release check|plan|notes|publish` guard after the +release preparation pull request is merged. Do not bypass it with direct +`basectl release` calls. Complete the Homebrew and Base downstream handoffs +documented in the release process. ## Shell Changes diff --git a/CHANGELOG.md b/CHANGELOG.md index c48e960..e91923a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,26 @@ and versions are tracked in the repo-root `VERSION` file. ## [Unreleased] +### Added + +- Added a repository-owned v2 release guard that permits only the planned + alpha, beta, release-candidate, and GA identifiers while locking publication + until the verified-artifact and pre-GA release-candidate gates are complete. + ### Fixed - Hardened file-section processing, allowed-dirty-path checks, temporary directory normalization, launcher symlink resolution, and pass-by-name output - handling for the v1.5.0 hardening train. + handling in post-v1.4.0 development. ### Documentation - Aligned the public Bash API documentation with implementation behavior, including list removal argument order, TTY detection, assertion semantics, temporary output arguments, and fatal function assertions. +- Documented the clean-break v2 release line, the withdrawn July 2026 v2 event, + immutable source pins, publication gates, and the canonical release-asset + requirement for Homebrew. ## [1.4.0] - 2026-07-25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ea0ea7..36492f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,9 @@ boundary. For release work, read [docs/release-process.md](docs/release-process.md). The repository release contract is declared in `base_manifest.yaml`; ordinary pull -requests leave `VERSION` unchanged. +requests leave `VERSION` unchanged. The active release line is documented in +[docs/versioning-policy.md](docs/versioning-policy.md), and every release +operation must enter through the repository-owned `scripts/release` guard. ## Workflow diff --git a/README.md b/README.md index 81e0ca7..3636793 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,15 @@ You can use a git checkout, tarball extract, or copied source tree without Homebrew. Keep the repository layout intact so `lib_std.sh` can find the root `VERSION` file: +Pin the checkout to the full current release commit instead of consuming the +moving default branch: + ```bash git clone https://github.com/basefoundry/base-bash-libs.git vendor/base-bash-libs +git -C vendor/base-bash-libs checkout --detach \ + 2c5ef2c3a9edfbe2cf68d0645be65b920255abff +test "$(git -C vendor/base-bash-libs rev-parse HEAD)" = \ + 2c5ef2c3a9edfbe2cf68d0645be65b920255abff ``` Source the stdlib from that checkout: @@ -173,6 +180,12 @@ The repo-root `VERSION` file is the source of truth for the package version. The top strip in this README and the runtime `BASE_BASH_LIBS_VERSION` constant are validated against that file. +`v1.4.0` remains stable during the clean-break v2 development train. The sole +next stable target is `v2.0.0`; there will be no stable v1.5.0 or version reset +to 0.x. See the [versioning and release-line policy](docs/versioning-policy.md) +for prerelease identifiers, publication gates, the withdrawn July 2026 v2 +event, immutable consumption, and the post-GA support contract. + Release preparation and downstream Homebrew/Base handoffs are documented in [`docs/release-process.md`](docs/release-process.md). The machine-readable release contract lives in [`base_manifest.yaml`](base_manifest.yaml). diff --git a/docs/release-process.md b/docs/release-process.md index 831ab4c..ac9f703 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -5,6 +5,12 @@ This repository declares its release contract in release commands and is the source of truth for the version file, changelog, GitHub Release, and Homebrew handoff. +The repository release line and its temporary publication gates are defined in +the [versioning policy](versioning-policy.md). Always enter the release workflow +through [`scripts/release`](../scripts/release); do not invoke +`basectl release` directly. The repository guard validates the candidate before +delegating safe operations to Base's generic release machinery. + ## Standard Sequence 1. Create or choose a release issue and set its repository Project metadata. @@ -24,17 +30,17 @@ GitHub Release, and Homebrew handoff. 6. Sync local `main`, then inspect the release from the repository root: ```bash - basectl release check --version X.Y.Z --manifest base_manifest.yaml - basectl release plan --version X.Y.Z --manifest base_manifest.yaml - basectl release notes --version X.Y.Z --manifest base_manifest.yaml - basectl release publish --version X.Y.Z --manifest base_manifest.yaml --dry-run + scripts/release check --version X.Y.Z --manifest base_manifest.yaml + scripts/release plan --version X.Y.Z --manifest base_manifest.yaml + scripts/release notes --version X.Y.Z --manifest base_manifest.yaml + scripts/release publish --version X.Y.Z --manifest base_manifest.yaml --dry-run ``` 7. Publish only after the readiness checks pass. Use `--yes` only from a trusted non-interactive release shell: ```bash - basectl release publish --version X.Y.Z --manifest base_manifest.yaml --yes + scripts/release publish --version X.Y.Z --manifest base_manifest.yaml --yes ``` 8. Verify the annotated `vX.Y.Z` tag and the GitHub Release for @@ -45,10 +51,12 @@ GitHub Release, and Homebrew handoff. The release contract requires the tap-owned formula `basefoundry/base/base-bash-libs` in `basefoundry/homebrew-base`. -After the GitHub Release exists: +After the GitHub Release and its verified canonical source asset exist: 1. Create a tap release branch and update `Formula/base-bash-libs.rb` to the - new archive URL, version, SHA256, and version assertions in the formula test. + canonical release-asset URL, version, SHA256, and version assertions in the + formula test. Do not use GitHub's automatic `archive/refs/tags/...` URL for + v2. 2. Validate the formula from the tap checkout: ```bash @@ -71,8 +79,9 @@ Base changelog when it is user-visible or release-relevant. ## Finish -Record the library release URL, Homebrew tap pull request, and Base dependency -pull request on the release issue. Remove the release worktree and merged -branches when safe. Do not publish a release while the worktree is dirty, the -version metadata disagrees, the changelog section is missing, or a declared +Record the library release URL, asset checksums and provenance, Homebrew tap +pull request, and Base dependency pull request on the release issue. Remove the +release worktree and merged branches when safe. Do not publish a release while +the worktree is dirty, the version metadata disagrees, the changelog section is +missing, the repository release guard blocks the candidate, or a declared downstream handoff has not been completed or explicitly deferred. diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md new file mode 100644 index 0000000..a218c48 --- /dev/null +++ b/docs/versioning-policy.md @@ -0,0 +1,116 @@ +# Versioning and Release-Line Policy + +## Current Release Line + +`v1.4.0` remains the stable Base Bash release while the 5/5 initiative in +[#214](https://github.com/basefoundry/base-bash-libs/issues/214) is in progress. +The initiative has one stable target: `v2.0.0`. + +The project will not publish a stable `v1.5.0` or reset its version to 0.x. +Those choices would either hide breaking changes inside the current 1.x +compatibility range or move version precedence backward. + +The only planned v2 identifiers before GA are: + +```text +2.0.0-alpha.N +2.0.0-beta.N +2.0.0-rc.N +2.0.0 +``` + +`N` starts at 1, increases within a phase, and has no leading zeroes. These +identifiers use SemVer syntax, but prereleases may contain breaking changes and +do not receive compatibility shims. The stable SemVer compatibility contract +begins at `v2.0.0` GA. + +After GA, v2 is the only supported release line. Versions through `v1.4.0` +remain available as historical releases but no longer receive fixes or support. +Additive features and fixes remain within 2.x; a breaking stable API change +requires v3 or later. + +## Release Gates + +The repository-owned [`scripts/release`](../scripts/release) command is the +mandatory entry point for every release inspection and publication attempt. It +enforces the permitted v2 identifiers before delegating read-only operations +and dry runs to Base's guarded release command. + +Real prerelease publication remains locked until #233 provides deterministic, +verified release assets and provenance. Real `v2.0.0` publication remains +locked until the engineering, policy, documentation, integration, and reference +application gates in #214 are complete and the pre-GA work in #240 has validated +and rehearsed the exact release candidate across Base, Base Demo, Homebrew, +vendored, and bundled paths. The remaining #240 steps then publish compatible +Base and Homebrew updates after the Base Bash GA asset exists. + +The lock is code-reviewed policy, not an environment-variable or sentinel-file +override. The PR that satisfies each gate must update the guard and its tests. +Until then, maintainers can inspect a candidate without changing GitHub state: + +```bash +scripts/release check --version 2.0.0-rc.1 --manifest base_manifest.yaml +scripts/release plan --version 2.0.0-rc.1 --manifest base_manifest.yaml +scripts/release publish --version 2.0.0-rc.1 --manifest base_manifest.yaml --dry-run +``` + +The generic `basectl release` command is not a substitute for this guard. Its +current manifest contract does not encode this repository's release line, +artifact, provenance, or GA gates. + +## Withdrawn July 2026 Event + +On July 2, 2026, [PR #100](https://github.com/basefoundry/base-bash-libs/pull/100) +created commit +[`2d90249`](https://github.com/basefoundry/base-bash-libs/commit/2d90249eec35aa00d04513294ce0fb09042c3f3f) +with `v2.0.0` metadata. A corresponding +[Homebrew PR #66](https://github.com/basefoundry/homebrew-base/pull/66) used +GitHub's automatic tag-archive URL. + +The next day, [PR #103](https://github.com/basefoundry/base-bash-libs/pull/103) +and commit +[`6ce8af0`](https://github.com/basefoundry/base-bash-libs/commit/6ce8af02031fad2c0071880b00eb6f526ae2d779) +corrected the release line to `v1.1.0`. +[Homebrew PR #68](https://github.com/basefoundry/homebrew-base/pull/68) +corrected the formula and added `version_scheme 1` so Homebrew would accept the +version-order correction. + +The attempted `v2.0.0` remote tag and GitHub Release are no longer present. +The commits and pull requests remain part of the public history, and caches of +the old automatic archive may still exist. The final v2 release therefore uses +a newly verified canonical release asset rather than that automatic archive. +The project will never silently retag or represent the withdrawn artifact as +the final release. + +Older local clones can retain the deleted lightweight tag. Inspect both sides +before removing a stale local ref: + +```bash +git ls-remote --tags origin refs/tags/v2.0.0 +git show-ref --verify refs/tags/v2.0.0 +``` + +If the remote command has no output and the local command finds the withdrawn +ref at `2d90249eec35aa00d04513294ce0fb09042c3f3f`, remove only that local tag: + +```bash +git tag -d v2.0.0 +``` + +## Immutable Consumption + +Do not install from an unpinned default-branch checkout. Until a verified v2 +asset exists, pin the current stable source to the full `v1.4.0` release commit: + +```bash +git clone https://github.com/basefoundry/base-bash-libs.git vendor/base-bash-libs +git -C vendor/base-bash-libs checkout --detach \ + 2c5ef2c3a9edfbe2cf68d0645be65b920255abff +test "$(git -C vendor/base-bash-libs rev-parse HEAD)" = \ + 2c5ef2c3a9edfbe2cf68d0645be65b920255abff +``` + +Prerelease validation must likewise use an immutable prerelease tag resolved to +its expected commit, a verified release asset, or a full commit. Release notes, +bug reports, and CI fixtures should record the resolved commit in addition to +the human-readable version. diff --git a/scripts/release b/scripts/release new file mode 100755 index 0000000..b5500a9 --- /dev/null +++ b/scripts/release @@ -0,0 +1,199 @@ +#!/usr/bin/env bash + +# Repository-owned release policy guard for the Base Bash v2 release line. + +release_usage() { + cat >&2 <<'EOF' +Usage: + scripts/release --version [options] + +Allowed release identifiers: + 2.0.0-alpha.N + 2.0.0-beta.N + 2.0.0-rc.N + 2.0.0 + +N must be a positive integer without leading zeroes. Real publication remains +locked until the verified-artifact and pre-GA release-candidate gates are +complete. +EOF +} + +release_error() { + printf 'ERROR: %s\n' "$*" >&2 +} + +release_version_kind() { + local version="${1-}" + local prerelease_re='^2[.]0[.]0-(alpha|beta|rc)[.]([1-9][0-9]*)$' + + if [[ "$version" == "2.0.0" ]]; then + printf 'ga\n' + return 0 + fi + if [[ "$version" =~ $prerelease_re ]]; then + printf 'prerelease\n' + return 0 + fi + return 1 +} + +main() { + local canonical_manifest + local command="${1-}" + local manifest_value="" + local version="" + local version_kind="" + local dry_run=0 + local index + local release_driver="${BASE_BASH_RELEASE_BASECTL:-basectl}" + local script_dir repo_root + local -a arguments=("$@") + local -a delegated_arguments=() + local seen_dry_run=0 + local seen_format=0 + local seen_manifest=0 + local seen_version=0 + local seen_yes=0 + + case "$command" in + check|plan|notes|publish) + ;; + ""|-h|--help|help) + release_usage + return 2 + ;; + *) + release_error "Unknown release command '$command'." + release_usage + return 2 + ;; + esac + + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || return 1 + repo_root="$(cd -- "$script_dir/.." && pwd -P)" || return 1 + canonical_manifest="$repo_root/base_manifest.yaml" + if [[ ! -f "$canonical_manifest" ]]; then + release_error "Canonical release manifest was not found at '$canonical_manifest'." + return 1 + fi + + index=1 + while ((index < ${#arguments[@]})); do + case "${arguments[$index]}" in + --version) + if ((seen_version)); then + release_error "Option '--version' may be provided only once." + return 2 + fi + seen_version=1 + index=$((index + 1)) + if ((index >= ${#arguments[@]})) || [[ -z "${arguments[$index]}" ]]; then + release_error "Option '--version' requires an argument." + return 2 + fi + version="${arguments[$index]}" + ;; + --manifest) + if ((seen_manifest)); then + release_error "Option '--manifest' may be provided only once." + return 2 + fi + seen_manifest=1 + index=$((index + 1)) + if ((index >= ${#arguments[@]})) || [[ -z "${arguments[$index]}" ]]; then + release_error "Option '--manifest' requires an argument." + return 2 + fi + manifest_value="${arguments[$index]}" + if [[ "$manifest_value" != "base_manifest.yaml" && + "$manifest_value" != "./base_manifest.yaml" && + "$manifest_value" != "$canonical_manifest" ]]; then + release_error "Release operations must use the canonical manifest at '$canonical_manifest'." + return 1 + fi + ;; + --format) + if [[ "$command" != "check" ]]; then + release_error "Option '--format' is only supported by release check." + return 2 + fi + if ((seen_format)); then + release_error "Option '--format' may be provided only once." + return 2 + fi + seen_format=1 + index=$((index + 1)) + if ((index >= ${#arguments[@]})) || [[ -z "${arguments[$index]}" ]]; then + release_error "Option '--format' requires an argument." + return 2 + fi + ;; + --dry-run) + if [[ "$command" != "publish" ]]; then + release_error "Option '--dry-run' is only supported by release publish." + return 2 + fi + if ((seen_dry_run)); then + release_error "Option '--dry-run' may be provided only once." + return 2 + fi + seen_dry_run=1 + dry_run=1 + ;; + --yes) + if [[ "$command" != "publish" ]]; then + release_error "Option '--yes' is only supported by release publish." + return 2 + fi + if ((seen_yes)); then + release_error "Option '--yes' may be provided only once." + return 2 + fi + seen_yes=1 + ;; + -h|--help) + release_usage + return 0 + ;; + *) + release_error "Unknown release $command option '${arguments[$index]}'." + return 2 + ;; + esac + index=$((index + 1)) + done + + if [[ -z "$version" ]]; then + release_error "Release commands require --version." + return 2 + fi + + version_kind="$(release_version_kind "$version")" || { + release_error "Version '$version' is outside the Base Bash v2.0.0 release line." + release_error "Use 2.0.0-alpha.N, 2.0.0-beta.N, 2.0.0-rc.N, or 2.0.0." + return 1 + } + + if [[ "$command" == "publish" && "$dry_run" -eq 0 ]]; then + if [[ "$version_kind" == "prerelease" ]]; then + release_error "Publishing $version is locked until verified release artifacts and provenance land in #233." + else + release_error "Publishing v2.0.0 GA is locked until the #214 pre-GA gates and #240 RC rehearsal are complete." + fi + release_error "Use 'publish --dry-run' to inspect the guarded plan without changing GitHub state." + return 1 + fi + + delegated_arguments=("$@") + if ((seen_manifest == 0)); then + delegated_arguments+=(--manifest "$canonical_manifest") + fi + + ( + cd -- "$repo_root" || exit 1 + "$release_driver" release "${delegated_arguments[@]}" + ) +} + +main "$@" diff --git a/tests/bash-42-release-smoke.sh b/tests/bash-42-release-smoke.sh new file mode 100755 index 0000000..a627a40 --- /dev/null +++ b/tests/bash-42-release-smoke.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +release_smoke_dir="" + +release_smoke_fail() { + printf 'Bash release-guard smoke failed: %s\n' "$*" >&2 + return 1 +} + +release_smoke_expect_blocked() { + local capture_path="$1" output_path="$2" + local status + shift 2 + + rm -f -- "$capture_path" + "$@" >"$output_path" 2>&1 + status=$? + if ((status == 0)); then + release_smoke_fail "blocked release command returned success." + return 1 + fi + if [[ -e "$capture_path" ]]; then + release_smoke_fail "blocked release command reached the delegated driver." + return 1 + fi + return 0 +} + +release_smoke_cleanup() { + if [[ -n "${release_smoke_dir-}" && -d "$release_smoke_dir" ]]; then + rm -rf -- "$release_smoke_dir" + fi +} + +main() { + local expected_major="${1-}" expected_minor="${2-}" expected_patch="${3-}" + local script_dir repo_root release_script release_driver capture_path output_path + + if (($# != 0 && $# != 3)); then + release_smoke_fail "usage: $0 [expected-major expected-minor expected-patch]" + return 1 + fi + + if (($# == 3)) && + [[ "${BASH_VERSINFO[0]}" != "$expected_major" || + "${BASH_VERSINFO[1]}" != "$expected_minor" || + "${BASH_VERSINFO[2]}" != "$expected_patch" ]]; then + release_smoke_fail "expected Bash $expected_major.$expected_minor.$expected_patch; running $BASH_VERSION." + return 1 + fi + + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || { + release_smoke_fail "unable to resolve the tests directory." + return 1 + } + repo_root="$(cd -- "$script_dir/.." && pwd -P)" || { + release_smoke_fail "unable to resolve the repository root." + return 1 + } + release_script="$repo_root/scripts/release" + release_smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/base-bash-release-smoke.XXXXXX")" || { + release_smoke_fail "unable to create the smoke workspace." + return 1 + } + trap release_smoke_cleanup EXIT + + release_driver="$repo_root/tests/fixtures/basectl-release-stub" + capture_path="$release_smoke_dir/delegated.out" + output_path="$release_smoke_dir/command.out" + export BASE_BASH_RELEASE_BASECTL="$release_driver" + export BASE_BASH_RELEASE_TEST_CAPTURE="$capture_path" + + if ! "$release_script" check --version 2.0.0-alpha.1 >"$output_path" 2>&1; then + release_smoke_fail "a supported prerelease check was not delegated." + return 1 + fi + grep -Fx 'arg=' "$capture_path" >/dev/null || return 1 + grep -Fx 'arg=' "$capture_path" >/dev/null || return 1 + grep -Fx "arg=<$repo_root/base_manifest.yaml>" "$capture_path" >/dev/null || return 1 + + release_smoke_expect_blocked "$capture_path" "$output_path" \ + "$release_script" check --version 1.5.0 || return 1 + release_smoke_expect_blocked "$capture_path" "$output_path" \ + "$release_script" publish --version 2.0.0 --yes || return 1 + release_smoke_expect_blocked "$capture_path" "$output_path" \ + "$release_script" publish --version 2.0.0 --manifest --dry-run --yes || return 1 + + printf 'Bash release-guard smoke passed on Bash %s.\n' "$BASH_VERSION" + return 0 +} + +main "$@" diff --git a/tests/fixtures/basectl-release-stub b/tests/fixtures/basectl-release-stub new file mode 100755 index 0000000..ee6b341 --- /dev/null +++ b/tests/fixtures/basectl-release-stub @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Parser-aware test double for the subset of `basectl release` used by the +# repository release guard. + +if [[ -z "${BASE_BASH_RELEASE_TEST_CAPTURE:-}" ]]; then + printf 'BASE_BASH_RELEASE_TEST_CAPTURE is required.\n' >&2 + exit 2 +fi + +printf 'cwd=<%s>\n' "$PWD" > "$BASE_BASH_RELEASE_TEST_CAPTURE" +for argument in "$@"; do + printf 'arg=<%s>\n' "$argument" >> "$BASE_BASH_RELEASE_TEST_CAPTURE" +done + +if [[ "${1-}" == "release" && "${2-}" == "publish" ]]; then + shift 2 + dry_run=0 + while (($#)); do + case "$1" in + --version|--manifest) + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + *) + shift + ;; + esac + done + if ((dry_run == 0)) && [[ -n "${BASE_BASH_RELEASE_TEST_PUBLISH_MARKER:-}" ]]; then + : > "$BASE_BASH_RELEASE_TEST_PUBLISH_MARKER" + fi +fi + +exit "${BASE_BASH_RELEASE_TEST_STATUS:-0}" diff --git a/tests/lint-warnings.sh b/tests/lint-warnings.sh index aa9d877..4f468c2 100755 --- a/tests/lint-warnings.sh +++ b/tests/lint-warnings.sh @@ -20,6 +20,9 @@ run_stage() { lint_files=( bin/base-bash + scripts/release + tests/fixtures/basectl-release-stub + tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh tests/validate.sh tests/lint-warnings.sh @@ -35,6 +38,7 @@ lint_files=( lib/bash/list/lib_list.sh lib/bash/tests/test_helper.sh tests/launcher.bats + tests/release.bats ) run_stage "ShellCheck warning profile" shellcheck --severity=warning "${lint_files[@]}" || exit $? diff --git a/tests/release.bats b/tests/release.bats new file mode 100644 index 0000000..b109cfe --- /dev/null +++ b/tests/release.bats @@ -0,0 +1,177 @@ +#!/usr/bin/env bats + +load ../lib/bash/tests/test_helper.sh + +setup() { + setup_test_tmpdir + RELEASE_SCRIPT="$BASE_REPO_ROOT/scripts/release" + RELEASE_DRIVER="$BASE_REPO_ROOT/tests/fixtures/basectl-release-stub" + RELEASE_CAPTURE="$TEST_TMPDIR/release-driver.out" + RELEASE_PUBLISH_MARKER="$TEST_TMPDIR/actual-publish" + export BASE_BASH_RELEASE_BASECTL="$RELEASE_DRIVER" + export BASE_BASH_RELEASE_TEST_CAPTURE="$RELEASE_CAPTURE" + export BASE_BASH_RELEASE_TEST_PUBLISH_MARKER="$RELEASE_PUBLISH_MARKER" + +} + +assert_driver_not_called() { + [ ! -e "$RELEASE_CAPTURE" ] +} + +@test "release guard delegates supported prerelease checks from the repository root" { + bats_run "$RELEASE_SCRIPT" check --version 2.0.0-alpha.1 \ + --manifest "$BASE_REPO_ROOT/base_manifest.yaml" + + [ "$status" -eq 0 ] + [ "$output" = "" ] + grep -F "cwd=<$BASE_REPO_ROOT>" "$RELEASE_CAPTURE" + grep -Fx 'arg=' "$RELEASE_CAPTURE" + grep -Fx 'arg=' "$RELEASE_CAPTURE" + grep -Fx 'arg=<--version>' "$RELEASE_CAPTURE" + grep -Fx 'arg=<2.0.0-alpha.1>' "$RELEASE_CAPTURE" + grep -Fx 'arg=<--manifest>' "$RELEASE_CAPTURE" + grep -Fx "arg=<$BASE_REPO_ROOT/base_manifest.yaml>" "$RELEASE_CAPTURE" +} + +@test "release guard injects the canonical manifest when it is omitted" { + bats_run "$RELEASE_SCRIPT" plan --version 2.0.0-alpha.1 + + [ "$status" -eq 0 ] + grep -Fx 'arg=<--manifest>' "$RELEASE_CAPTURE" + grep -Fx "arg=<$BASE_REPO_ROOT/base_manifest.yaml>" "$RELEASE_CAPTURE" +} + +@test "release guard accepts every defined prerelease phase" { + local version + + for version in 2.0.0-alpha.9 2.0.0-beta.10 2.0.0-rc.3; do + rm -f "$RELEASE_CAPTURE" + bats_run "$RELEASE_SCRIPT" plan --version "$version" + + [ "$status" -eq 0 ] + grep -Fx "arg=<$version>" "$RELEASE_CAPTURE" + done +} + +@test "release guard preserves the delegated exit status" { + export BASE_BASH_RELEASE_TEST_STATUS=17 + + bats_run "$RELEASE_SCRIPT" notes --version 2.0.0-rc.1 + + [ "$status" -eq 17 ] +} + +@test "release guard rejects stable 0.x and 1.x releases" { + local version + + for version in 0.9.0 1.4.0 1.4.1 1.5.0; do + bats_run "$RELEASE_SCRIPT" check --version "$version" + + [ "$status" -eq 1 ] + [[ "$output" == *"outside the Base Bash v2.0.0 release line"* ]] + done + assert_driver_not_called +} + +@test "release guard rejects malformed and unplanned v2 identifiers" { + local version + + for version in \ + 2.0.0-alpha.0 \ + 2.0.0-alpha.01 \ + 2.0.0-preview.1 \ + 2.0.0-alpha \ + 2.0.0-alpha.1+build.1 \ + 2.0.1 \ + 2.1.0 \ + 3.0.0; do + bats_run "$RELEASE_SCRIPT" check --version "$version" + + [ "$status" -eq 1 ] + done + assert_driver_not_called +} + +@test "release guard locks real prerelease publication" { + bats_run "$RELEASE_SCRIPT" publish --version 2.0.0-beta.1 --yes + + [ "$status" -eq 1 ] + [[ "$output" == *"locked until verified release artifacts and provenance land in #233"* ]] + assert_driver_not_called +} + +@test "release guard locks real v2 GA publication" { + bats_run "$RELEASE_SCRIPT" publish --version 2.0.0 --yes + + [ "$status" -eq 1 ] + [[ "$output" == *"#240 RC rehearsal"* ]] + assert_driver_not_called +} + +@test "release guard does not mistake a manifest value for a dry-run flag" { + bats_run "$RELEASE_SCRIPT" publish --version 2.0.0 --manifest --dry-run --yes + + [ "$status" -eq 1 ] + [[ "$output" == *"must use the canonical manifest"* ]] + assert_driver_not_called + [ ! -e "$RELEASE_PUBLISH_MARKER" ] +} + +@test "release guard rejects alternate and external manifests" { + local manifest_path + + for manifest_path in alternate.yaml ../base_manifest.yaml "$TEST_TMPDIR/base_manifest.yaml"; do + bats_run "$RELEASE_SCRIPT" check --version 2.0.0-rc.1 --manifest "$manifest_path" + + [ "$status" -eq 1 ] + [[ "$output" == *"must use the canonical manifest"* ]] + done + assert_driver_not_called +} + +@test "release guard delegates prerelease publish dry runs without losing arguments" { + bats_run "$RELEASE_SCRIPT" publish --yes --version 2.0.0-rc.4 --dry-run + + [ "$status" -eq 0 ] + grep -Fx 'arg=' "$RELEASE_CAPTURE" + grep -Fx 'arg=<--yes>' "$RELEASE_CAPTURE" + grep -Fx 'arg=<--version>' "$RELEASE_CAPTURE" + grep -Fx 'arg=<2.0.0-rc.4>' "$RELEASE_CAPTURE" + grep -Fx 'arg=<--dry-run>' "$RELEASE_CAPTURE" +} + +@test "release guard allows GA readiness and dry-run inspection without publishing" { + bats_run "$RELEASE_SCRIPT" check --version 2.0.0 + [ "$status" -eq 0 ] + + rm -f "$RELEASE_CAPTURE" + bats_run "$RELEASE_SCRIPT" publish --version 2.0.0 --dry-run + [ "$status" -eq 0 ] + grep -Fx 'arg=<--dry-run>' "$RELEASE_CAPTURE" +} + +@test "release guard rejects missing versions and unknown commands as usage errors" { + bats_run "$RELEASE_SCRIPT" check + [ "$status" -eq 2 ] + [[ "$output" == *"require --version"* ]] + + bats_run "$RELEASE_SCRIPT" deploy --version 2.0.0-rc.1 + [ "$status" -eq 2 ] + [[ "$output" == *"Unknown release command"* ]] + assert_driver_not_called +} + +@test "release guard fails closed on duplicate and unsupported options" { + bats_run "$RELEASE_SCRIPT" publish --version 2.0.0 --version 2.0.0 --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"Option '--version' may be provided only once."* ]] + + bats_run "$RELEASE_SCRIPT" publish --version 2.0.0 --force --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"Unknown release publish option '--force'"* ]] + + bats_run "$RELEASE_SCRIPT" check --version 2.0.0 --dry-run + [ "$status" -eq 2 ] + [[ "$output" == *"Option '--dry-run' is only supported by release publish."* ]] + assert_driver_not_called +} diff --git a/tests/validate.sh b/tests/validate.sh index 4779da8..3879104 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -12,9 +12,13 @@ required_files=( LICENSE NOTICE base_manifest.yaml + docs/versioning-policy.md .github/workflows/project-intake.yml .github/workflows/tests.yml bin/base-bash + scripts/release + tests/fixtures/basectl-release-stub + tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh examples/std-usage.sh examples/cookbook-cleanup-temp.sh @@ -40,6 +44,7 @@ required_files=( lib/bash/list/tests/lib_list.bats lib/bash/tests/test_helper.sh tests/launcher.bats + tests/release.bats tests/lint-warnings.sh ) @@ -64,6 +69,9 @@ check_no_strict_mode() { local file matches status local strict_mode_files=( bin/base-bash + scripts/release + tests/fixtures/basectl-release-stub + tests/bash-42-release-smoke.sh tests/bash-42-logging-smoke.sh tests/validate.sh tests/lint-warnings.sh @@ -193,6 +201,9 @@ done run_stage "ShellCheck error profile" shellcheck --severity=error \ bin/base-bash \ + scripts/release \ + tests/fixtures/basectl-release-stub \ + tests/bash-42-release-smoke.sh \ tests/bash-42-logging-smoke.sh \ tests/validate.sh \ tests/lint-warnings.sh \ @@ -207,10 +218,12 @@ run_stage "ShellCheck error profile" shellcheck --severity=error \ lib/bash/arg/lib_arg.sh \ lib/bash/list/lib_list.sh \ lib/bash/tests/test_helper.sh \ - tests/launcher.bats + tests/launcher.bats \ + tests/release.bats bats_files=( tests/launcher.bats + tests/release.bats lib/bash/std/tests/lib_std.bats lib/bash/file/tests/lib_file.bats lib/bash/git/tests/lib_git.bats @@ -224,6 +237,7 @@ run_stage "BATS test suites" bats \ "${bats_files[@]}" || exit $? run_stage "Bash logging smoke" tests/bash-42-logging-smoke.sh || exit $? +run_stage "Bash release guard smoke" tests/bash-42-release-smoke.sh || exit $? run_stage "examples/std-usage.sh" examples/std-usage.sh >/dev/null || exit $? run_stage "examples/cookbook-cleanup-temp.sh" examples/cookbook-cleanup-temp.sh >/dev/null || exit $? run_stage "examples/cookbook-args-lists-strings.sh" examples/cookbook-args-lists-strings.sh >/dev/null || exit $?