From 1b13368d3c335482e1d79a467562d1c384b29427 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:15:57 +0530 Subject: [PATCH] bug: support post-GA v2 SemVer releases (#349) --- CHANGELOG.md | 2 + docs/api-manifest-schema.md | 2 +- docs/versioning-policy.md | 48 +++--- scripts/api-manifest | 256 +++++++++++++++++++----------- scripts/release | 43 ++--- scripts/release-artifact | 12 +- scripts/release-version-policy.sh | 25 +++ tests/api-manifest.bats | 17 ++ tests/release-artifact.bats | 19 ++- tests/release.bats | 38 ++++- tests/validate.sh | 11 +- 11 files changed, 314 insertions(+), 159 deletions(-) create mode 100644 scripts/release-version-policy.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ac8ef..ffbedb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and versions are tracked in the repo-root `VERSION` file. repeatable itself, independent of earlier fixed positional arguments. - Made CLI completion consume option values and stop command and option interpretation after `--`, matching the runtime parser's path semantics. +- Replaced the frozen `2.0.0` release guards with one shared post-GA SemVer + policy for stable v2 patch/minor releases and approved prerelease channels. - Made table-driven CLI declaration transactional so any late semantic error restores an existing model exactly or removes a partial new model. - Rejected unreachable CLI command routes and ancestor/child option collisions, diff --git a/docs/api-manifest-schema.md b/docs/api-manifest-schema.md index bb0999d..b9b3332 100644 --- a/docs/api-manifest-schema.md +++ b/docs/api-manifest-schema.md @@ -15,7 +15,7 @@ The current schema is `1` and the manifest identifies itself with | Field | Meaning | | --- | --- | | `schema_version` | Manifest schema compatibility number. | -| `manifest_version` | API release line represented by the manifest (`2.0.0` during v2 development). | +| `manifest_version` | API release represented by the manifest; it follows the repository's shared post-GA v2 SemVer policy. | | `minimum_bash` | Minimum supported Bash runtime (`4.2`). | | `generated_reference` | Checked-in API reference generated from this manifest. | | `migration_inventory` | Normative v1-to-v2 behavior and symbol migration record. | diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index deae426..14c88b5 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -10,19 +10,20 @@ or reset the 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: +Post-GA releases use one repository-owned v2 SemVer policy: ```text -2.0.0-alpha.N -2.0.0-beta.N -2.0.0-rc.N -2.0.0 +2.MINOR.PATCH +2.MINOR.PATCH-alpha.N +2.MINOR.PATCH-beta.N +2.MINOR.PATCH-rc.N ``` -`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 +`MINOR` and `PATCH` are non-negative canonical decimal integers; `N` starts at +1 and has no leading zeroes. Build metadata and prerelease channels other than +`alpha`, `beta`, and `rc` are not accepted by the release workflow. Prereleases do not receive compatibility shims. The stable SemVer compatibility contract -begins at `v2.0.0` GA. +began 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. @@ -33,8 +34,9 @@ requires v3 or later. 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. +sources [`scripts/release-version-policy.sh`](../scripts/release-version-policy.sh) +before delegating read-only operations and dry runs to Base's guarded release +command. The artifact builder and repository validation source the same policy. Prerelease publication became available because #233 and the follow-up release-artifact contract landed. The reviewed `v2.0.0-rc.1` artifact and the @@ -44,15 +46,14 @@ Base, Base Demo, Homebrew, vendored, and bundled paths. The canonical the first-party handoff is complete; future releases must preserve the same verified-asset contract. -The GA 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. Maintainers can inspect any candidate without changing GitHub -state: +The release policy is code reviewed, not an environment-variable or +sentinel-file override. Maintainers can inspect any 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 +scripts/release check --version 2.1.0 --manifest base_manifest.yaml +scripts/release plan --version 2.1.0 --manifest base_manifest.yaml +scripts/release publish --version 2.1.0 --manifest base_manifest.yaml --dry-run ``` The generic `basectl release` command is not a substitute for this guard. Its @@ -62,10 +63,10 @@ artifact, provenance, or GA gates. Before any real publication attempt, run the repository-owned tag preflight: ```bash -scripts/release refs --version 2.0.0-rc.1 +scripts/release refs --version 2.1.0 ``` -The preflight checks both `refs/tags/v2.0.0-rc.1` in the local checkout and the +The preflight checks both the exact candidate tag in the local checkout and the same tag on `origin`. It fails closed when Git cannot inspect either side or when the tag is already present. Published tags are immutable; a stale local tag may be removed only after confirming that the remote ref is absent and that @@ -115,15 +116,16 @@ git tag -d v2.0.0 The complete checkout, archive, Homebrew, vendored, and standalone verification procedure is maintained in [`pinned-consumption.md`](pinned-consumption.md). -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: +Do not install from an unpinned default-branch checkout. Use the canonical +`v2.0.0` release asset and verify its checksum, or pin the stable source to the +full commit resolved from the signed `v2.0.0` tag: ```bash git clone https://github.com/basefoundry/base-bash-libs.git vendor/base-bash-libs git -C vendor/base-bash-libs checkout --detach \ - 2c5ef2c3a9edfbe2cf68d0645be65b920255abff + b4243765726c133499feeabdc50154f99c0fec12 test "$(git -C vendor/base-bash-libs rev-parse HEAD)" = \ - 2c5ef2c3a9edfbe2cf68d0645be65b920255abff + b4243765726c133499feeabdc50154f99c0fec12 ``` Prerelease validation must likewise use an immutable prerelease tag resolved to diff --git a/scripts/api-manifest b/scripts/api-manifest index 1b20cc9..ed030d0 100755 --- a/scripts/api-manifest +++ b/scripts/api-manifest @@ -7,9 +7,11 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || exit 1 repo_root="$(cd -- "$script_dir/.." && pwd -P)" || exit 1 default_manifest="$repo_root/base_api_manifest.yaml" +# shellcheck source=release-version-policy.sh +source "$repo_root/scripts/release-version-policy.sh" || exit 1 usage() { - cat >&2 <<'EOF' + cat >&2 << 'EOF' Usage: scripts/api-manifest check [manifest] scripts/api-manifest generate [manifest] [output] @@ -206,10 +208,16 @@ check_manifest() { local -A module_tests=() local -A module_docs=() - [[ -f "$manifest" ]] || { error "manifest not found: $manifest"; return 1; } + [[ -f "$manifest" ]] || { + error "manifest not found: $manifest" + return 1 + } for field in schema_version manifest_kind manifest_version minimum_bash generated_reference migration_inventory; do value="$(manifest_value "$manifest" "$field")" - [[ -n "$value" ]] || { error "missing top-level field '$field'"; return 1; } + [[ -n "$value" ]] || { + error "missing top-level field '$field'" + return 1 + } done [[ "$(manifest_value "$manifest" schema_version)" == 1 ]] || { error "schema_version must be 1" @@ -219,8 +227,8 @@ check_manifest() { error "manifest_kind must be base-bash-libs-api" return 1 } - [[ "$(manifest_value "$manifest" manifest_version)" =~ ^2[.]0[.]0([-.].*)?$ ]] || { - error "manifest_version must be on the v2.0.0 release line" + base_bash_release_version_supported "$(manifest_value "$manifest" manifest_version)" || { + error "manifest_version must follow the supported Base Bash v2 release policy" return 1 } [[ "$(manifest_value "$manifest" minimum_bash)" =~ ^4[.]2$ ]] || { @@ -241,25 +249,49 @@ check_manifest() { } value="$(manifest_value "$manifest" generated_reference)" - path_is_safe "$value" || { error "generated_reference is not a safe relative path"; return 1; } - [[ -f "$repo_root/$value" ]] || { error "generated reference is missing: $value"; return 1; } + path_is_safe "$value" || { + error "generated_reference is not a safe relative path" + return 1 + } + [[ -f "$repo_root/$value" ]] || { + error "generated reference is missing: $value" + return 1 + } value="$(manifest_value "$manifest" migration_inventory)" - path_is_safe "$value" || { error "migration_inventory is not a safe relative path"; return 1; } - [[ -f "$repo_root/$value" ]] || { error "migration inventory is missing: $value"; return 1; } + path_is_safe "$value" || { + error "migration_inventory is not a safe relative path" + return 1 + } + [[ -f "$repo_root/$value" ]] || { + error "migration inventory is missing: $value" + return 1 + } while IFS= read -r row; do - [[ "$row" == *'|'*'|'* ]] || { error "environment entry must be NAME|kind|description: $row"; return 1; } + [[ "$row" == *'|'*'|'* ]] || { + error "environment entry must be NAME|kind|description: $row" + return 1 + } done < <(manifest_section_rows "$manifest" environment) value="$(manifest_value "$manifest" optional_commands)" - [[ -n "$value" ]] || { error "optional_commands must not be empty"; return 1; } + [[ -n "$value" ]] || { + error "optional_commands must not be empty" + return 1 + } while IFS= read -r field; do - [[ "$field" =~ ^[a-zA-Z0-9._+-]+$ ]] || { error "invalid optional command '$field'"; return 1; } + [[ "$field" =~ ^[a-zA-Z0-9._+-]+$ ]] || { + error "invalid optional command '$field'" + return 1 + } done < <(split_csv "$value") while IFS=$'\t' read -r -a fields; do [[ -n "${fields[0]-}" ]] || continue module_count=$((module_count + 1)) - [[ -z "${module_names[${fields[0]}]-}" ]] || { error "duplicate module '${fields[0]}'"; return 1; } + [[ -z "${module_names[${fields[0]}]-}" ]] || { + error "duplicate module '${fields[0]}'" + return 1 + } module_names["${fields[0]}"]=1 module_dependencies["${fields[0]}"]="${fields[5]}" module_sources["${fields[0]}"]="${fields[2]}" @@ -270,8 +302,14 @@ check_manifest() { return 1 } for field in "${fields[2]}" "${fields[3]}" "${fields[4]}"; do - path_is_safe "$field" || { error "module '${fields[0]}' has an unsafe path '$field'"; return 1; } - [[ -f "$repo_root/$field" ]] || { error "module '${fields[0]}' path is missing: $field"; return 1; } + path_is_safe "$field" || { + error "module '${fields[0]}' has an unsafe path '$field'" + return 1 + } + [[ -f "$repo_root/$field" ]] || { + error "module '${fields[0]}' path is missing: $field" + return 1 + } done [[ "${fields[15]}" =~ ^2[.]0[.]0([-.].*)?$ ]] || { error "module '${fields[0]}' has invalid since metadata" @@ -283,33 +321,60 @@ check_manifest() { } IFS=',' read -r -a artifacts <<< "${fields[7]}" for artifact in "${artifacts[@]}"; do - path_is_safe "$artifact" || { error "module '${fields[0]}' has an unsafe artifact '$artifact'"; return 1; } - [[ -f "$repo_root/$artifact" ]] || { error "module '${fields[0]}' artifact is missing: $artifact"; return 1; } + path_is_safe "$artifact" || { + error "module '${fields[0]}' has an unsafe artifact '$artifact'" + return 1 + } + [[ -f "$repo_root/$artifact" ]] || { + error "module '${fields[0]}' artifact is missing: $artifact" + return 1 + } done - [[ ",${fields[7]}," == *",${fields[2]},"* ]] || { error "module '${fields[0]}' does not package its source"; return 1; } - [[ ",${fields[7]}," == *",${fields[3]},"* ]] || { error "module '${fields[0]}' does not package its documentation"; return 1; } - [[ ",${fields[7]}," == *",${fields[4]},"* ]] || { error "module '${fields[0]}' does not package its tests"; return 1; } + [[ ",${fields[7]}," == *",${fields[2]},"* ]] || { + error "module '${fields[0]}' does not package its source" + return 1 + } + [[ ",${fields[7]}," == *",${fields[3]},"* ]] || { + error "module '${fields[0]}' does not package its documentation" + return 1 + } + [[ ",${fields[7]}," == *",${fields[4]},"* ]] || { + error "module '${fields[0]}' does not package its tests" + return 1 + } IFS=',' read -r -a symbols <<< "${fields[8]}" - [[ ${#symbols[@]} -gt 0 ]] || { error "module '${fields[0]}' declares no public symbols"; return 1; } + [[ ${#symbols[@]} -gt 0 ]] || { + error "module '${fields[0]}' declares no public symbols" + return 1 + } source_symbols_list=() while IFS= read -r source_symbol; do source_symbols_list+=("$source_symbol"); done < <(source_symbols "$repo_root/${fields[2]}") for symbol in "${symbols[@]}"; do - [[ "$symbol" =~ ^base_[a-zA-Z0-9_]+$ ]] || { error "invalid public symbol '$symbol'"; return 1; } - [[ -z "${manifest_symbols[$symbol]-}" ]] || { error "duplicate public symbol '$symbol'"; return 1; } + [[ "$symbol" =~ ^base_[a-zA-Z0-9_]+$ ]] || { + error "invalid public symbol '$symbol'" + return 1 + } + [[ -z "${manifest_symbols[$symbol]-}" ]] || { + error "duplicate public symbol '$symbol'" + return 1 + } manifest_symbols[$symbol]=${fields[0]} - printf '%s\n' "${source_symbols_list[@]}" | grep -Fx "$symbol" >/dev/null || { + printf '%s\n' "${source_symbols_list[@]}" | grep -Fx "$symbol" > /dev/null || { error "manifest symbol '$symbol' is not declared in ${fields[2]}" return 1 } done for source_symbol in "${source_symbols_list[@]}"; do - printf '%s\n' "${symbols[@]}" | grep -Fx "$source_symbol" >/dev/null || { + printf '%s\n' "${symbols[@]}" | grep -Fx "$source_symbol" > /dev/null || { error "public source symbol '$source_symbol' is missing from module '${fields[0]}'" return 1 } done done < <(manifest_rows "$manifest") - ((module_count > 0)) || { error "manifest declares no modules"; return 1; } + ((module_count > 0)) || { + error "manifest declares no modules" + return 1 + } for module in "${!module_names[@]}"; do module_cycle_check "$module" || return 1 @@ -317,8 +382,14 @@ check_manifest() { while IFS= read -r row; do [[ -n "$row" ]] || continue field="${row%%|*}" - path_is_safe "$field" || { error "artifact entry has an unsafe path '$field'"; return 1; } - [[ -f "$repo_root/$field" ]] || { error "artifact entry is missing: $field"; return 1; } + path_is_safe "$field" || { + error "artifact entry has an unsafe path '$field'" + return 1 + } + [[ -f "$repo_root/$field" ]] || { + error "artifact entry is missing: $field" + return 1 + } done < <(manifest_section_rows "$manifest" artifacts) return 0 @@ -334,75 +405,78 @@ main() { local output="" local row field case "$command" in - check|generate|symbols|module-paths|source-paths|test-paths|artifact-paths) - shift - manifest="$(manifest_or_default "${1-}")" - [[ $# -eq 0 || $# -eq 1 || "$command" == generate ]] || { usage; return 2; } - ;; - -h|--help|help|"") - usage - return 2 - ;; - *) + check | generate | symbols | module-paths | source-paths | test-paths | artifact-paths) + shift + manifest="$(manifest_or_default "${1-}")" + [[ $# -eq 0 || $# -eq 1 || "$command" == generate ]] || { usage return 2 - ;; + } + ;; + -h | --help | help | "") + usage + return 2 + ;; + *) + usage + return 2 + ;; esac case "$command" in - check) - check_manifest "$manifest" || return $? - output="$(mktemp "${TMPDIR:-/tmp}/base-api-manifest.XXXXXX")" || return 1 - if ! generate_reference "$manifest" > "$output"; then - rm -f "$output" - return 1 - fi - if ! cmp -s "$output" "$repo_root/$(manifest_value "$manifest" generated_reference)"; then - error "generated API reference is stale; run 'scripts/api-manifest generate'" - rm -f "$output" - return 1 - fi + check) + check_manifest "$manifest" || return $? + output="$(mktemp "${TMPDIR:-/tmp}/base-api-manifest.XXXXXX")" || return 1 + if ! generate_reference "$manifest" > "$output"; then rm -f "$output" - printf 'API manifest is valid.\n' - ;; - generate) - output="${2-}" - if [[ -n "$output" ]]; then - generate_reference "$manifest" > "$repo_root/$output" - else - generate_reference "$manifest" - fi - ;; - symbols) - check_manifest "$manifest" || return $? - while IFS=$'\t' read -r -a fields; do - IFS=',' read -r -a symbols <<< "${fields[8]}" - printf '%s\n' "${symbols[@]}" - done < <(manifest_rows "$manifest") - ;; - module-paths) - check_manifest "$manifest" || return $? - manifest_rows "$manifest" | cut -f2,4,5 - ;; - source-paths) - check_manifest "$manifest" || return $? - manifest_rows "$manifest" | cut -f3 - ;; - test-paths) - check_manifest "$manifest" || return $? - manifest_rows "$manifest" | cut -f5 - ;; - artifact-paths) - check_manifest "$manifest" || return $? - while IFS= read -r row; do - field="${row%%|*}" - printf '%s\n' "$field" - done < <(manifest_section_rows "$manifest" artifacts) - while IFS=$'\t' read -r -a fields; do - IFS=',' read -r -a artifacts <<< "${fields[7]}" - printf '%s\n' "${artifacts[@]}" - done < <(manifest_rows "$manifest") - ;; + return 1 + fi + if ! cmp -s "$output" "$repo_root/$(manifest_value "$manifest" generated_reference)"; then + error "generated API reference is stale; run 'scripts/api-manifest generate'" + rm -f "$output" + return 1 + fi + rm -f "$output" + printf 'API manifest is valid.\n' + ;; + generate) + output="${2-}" + if [[ -n "$output" ]]; then + generate_reference "$manifest" > "$repo_root/$output" + else + generate_reference "$manifest" + fi + ;; + symbols) + check_manifest "$manifest" || return $? + while IFS=$'\t' read -r -a fields; do + IFS=',' read -r -a symbols <<< "${fields[8]}" + printf '%s\n' "${symbols[@]}" + done < <(manifest_rows "$manifest") + ;; + module-paths) + check_manifest "$manifest" || return $? + manifest_rows "$manifest" | cut -f2,4,5 + ;; + source-paths) + check_manifest "$manifest" || return $? + manifest_rows "$manifest" | cut -f3 + ;; + test-paths) + check_manifest "$manifest" || return $? + manifest_rows "$manifest" | cut -f5 + ;; + artifact-paths) + check_manifest "$manifest" || return $? + while IFS= read -r row; do + field="${row%%|*}" + printf '%s\n' "$field" + done < <(manifest_section_rows "$manifest" artifacts) + while IFS=$'\t' read -r -a fields; do + IFS=',' read -r -a artifacts <<< "${fields[7]}" + printf '%s\n' "${artifacts[@]}" + done < <(manifest_rows "$manifest") + ;; esac } diff --git a/scripts/release b/scripts/release index 006e5a9..0c83781 100755 --- a/scripts/release +++ b/scripts/release @@ -2,25 +2,27 @@ # Repository-owned release policy guard for the Base Bash v2 release line. +release_script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || exit 1 +# shellcheck source=release-version-policy.sh +source "$release_script_dir/release-version-policy.sh" || exit 1 + 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 + 2.MINOR.PATCH + 2.MINOR.PATCH-alpha.N + 2.MINOR.PATCH-beta.N + 2.MINOR.PATCH-rc.N -N must be a positive integer without leading zeroes. Prerelease publication is -available after the verified release-artifact contract has landed; GA remains -locked until the pre-GA release-candidate gates are complete. +MINOR and PATCH are non-negative integers without leading zeroes. N is a +positive integer without leading zeroes. Stable and approved prerelease +publication use the same verified-artifact and immutable-tag gates. The refs command is a read-only preflight that fails closed when the candidate -tag already exists locally or on origin. GA publication is available only -after the reviewed RC artifact, first-party consumer, and rollback gates are -complete. +tag already exists locally or on origin. Published tags are immutable. EOF } @@ -28,21 +30,6 @@ 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 -} - release_check_tag_refs() { local repo_root="$1" local tag_name="$2" @@ -208,9 +195,9 @@ main() { return 2 fi - release_version_kind "$version" > /dev/null || { - 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." + base_bash_release_version_supported "$version" || { + release_error "Version '$version' is outside the supported Base Bash v2 release policy." + release_error "Use 2.MINOR.PATCH or an approved alpha.N, beta.N, or rc.N prerelease." return 1 } diff --git a/scripts/release-artifact b/scripts/release-artifact index 2c23a56..48a66b3 100755 --- a/scripts/release-artifact +++ b/scripts/release-artifact @@ -7,6 +7,8 @@ # manifest that can be attached to the guarded release operation. repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 +# shellcheck source=release-version-policy.sh +source "$repo_root/scripts/release-version-policy.sh" || exit 1 usage() { cat >&2 << 'EOF' @@ -44,10 +46,6 @@ json_escape() { printf '%s' "$value" } -version_is_supported() { - [[ "${1-}" =~ ^2[.]0[.]0(-(alpha|beta|rc)[.]([1-9][0-9]*))?$ ]] -} - commit_is_full_sha() { [[ "${1-}" =~ ^[[:xdigit:]]{40}$ ]] } @@ -211,8 +209,8 @@ build_artifact() { esac index=$((index + 1)) done - version_is_supported "$version" || { - error "Version '$version' is outside the Base Bash v2.0.0 release line." + base_bash_release_version_supported "$version" || { + error "Version '$version' is outside the supported Base Bash v2 release policy." return 1 } [[ -n "$output" ]] || { @@ -335,7 +333,7 @@ verify_artifact() { archive_name="${archive##*/}" version="${archive_name#base-bash-libs-v}" version="${version%.tar.gz}" - version_is_supported "$version" || { + base_bash_release_version_supported "$version" || { error "Archive version is not supported: $version" return 1 } diff --git a/scripts/release-version-policy.sh b/scripts/release-version-policy.sh new file mode 100644 index 0000000..8e6e71a --- /dev/null +++ b/scripts/release-version-policy.sh @@ -0,0 +1,25 @@ +# shellcheck shell=bash + +# One repository-owned SemVer policy for the supported v2 release line. +# Stable releases use 2.MINOR.PATCH. Approved prereleases append +# -(alpha|beta|rc).N, where every numeric component is canonical decimal. +base_bash_release_version_kind() { + local version="${1-}" + local numeric='(0|[1-9][0-9]*)' + local stable_re="^2[.]${numeric}[.]${numeric}$" + local prerelease_re="^2[.]${numeric}[.]${numeric}-(alpha|beta|rc)[.]([1-9][0-9]*)$" + + if [[ "$version" =~ $stable_re ]]; then + printf 'stable\n' + return 0 + fi + if [[ "$version" =~ $prerelease_re ]]; then + printf 'prerelease\n' + return 0 + fi + return 1 +} + +base_bash_release_version_supported() { + base_bash_release_version_kind "${1-}" > /dev/null +} diff --git a/tests/api-manifest.bats b/tests/api-manifest.bats index e969f82..9835807 100644 --- a/tests/api-manifest.bats +++ b/tests/api-manifest.bats @@ -50,3 +50,20 @@ setup() { [ "$status" -ne 0 ] [[ "$output" == *"dependency cycle"* ]] } + +@test "manifest version uses the shared post-GA v2 release policy" { + local manifest="$TEST_TMPDIR/manifest.yaml" + + sed 's/manifest_version: 2.0.0/manifest_version: 2.1.0/' \ + "$BASE_REPO_ROOT/base_api_manifest.yaml" > "$manifest" + run "$BASE_REPO_ROOT/scripts/api-manifest" check "$manifest" + [ "$status" -ne 0 ] + [[ "$output" == *"generated API reference is stale"* ]] + [[ "$output" != *"supported Base Bash v2 release policy"* ]] + + sed 's/manifest_version: 2.0.0/manifest_version: 2.01.0/' \ + "$BASE_REPO_ROOT/base_api_manifest.yaml" > "$manifest" + run "$BASE_REPO_ROOT/scripts/api-manifest" check "$manifest" + [ "$status" -ne 0 ] + [[ "$output" == *"supported Base Bash v2 release policy"* ]] +} diff --git a/tests/release-artifact.bats b/tests/release-artifact.bats index 6026256..7804109 100644 --- a/tests/release-artifact.bats +++ b/tests/release-artifact.bats @@ -29,7 +29,8 @@ release_test_refresh_checksum() { } release_test_build() { - "$RELEASE_ARTIFACT" build --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$1" > /dev/null + local destination="$1" version="${2:-2.0.0-rc.1}" + "$RELEASE_ARTIFACT" build --version "$version" --commit "$RELEASE_COMMIT" --output "$destination" > /dev/null } @test "release artifact build creates a deterministic verified asset set" { @@ -48,6 +49,22 @@ release_test_build() { grep -F '"reproducible": true' "$first"/*.provenance.json } +@test "release artifact build and verify support post-GA patch and minor versions" { + local version artifact + + for version in 2.0.1 2.1.0; do + artifact="$TEST_TMPDIR/artifact-$version" + release_test_build "$artifact" "$version" + bats_run "$RELEASE_ARTIFACT" verify "$artifact" + [ "$status" -eq 0 ] + [[ "$output" == *"verified"* ]] + [ -f "$artifact/base-bash-libs-v$version.tar.gz" ] + [ -f "$artifact/base-bash-libs-v$version.spdx.json" ] + [ -f "$artifact/base-bash-libs-v$version.provenance.json" ] + [ -f "$artifact/base-bash-libs-v$version.SHA256SUMS" ] + done +} + @test "release artifact verification rejects a tampered asset" { local output="$TEST_TMPDIR/artifact" diff --git a/tests/release.bats b/tests/release.bats index e598dea..312764b 100644 --- a/tests/release.bats +++ b/tests/release.bats @@ -74,7 +74,7 @@ assert_driver_not_called() { @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 + for version in 2.0.0-alpha.9 2.0.1-beta.10 2.1.0-rc.3; do rm -f "$RELEASE_CAPTURE" bats_run "$RELEASE_SCRIPT" plan --version "$version" @@ -98,7 +98,7 @@ assert_driver_not_called() { bats_run "$RELEASE_SCRIPT" check --version "$version" [ "$status" -eq 1 ] - [[ "$output" == *"outside the Base Bash v2.0.0 release line"* ]] + [[ "$output" == *"outside the supported Base Bash v2 release policy"* ]] done assert_driver_not_called } @@ -112,8 +112,12 @@ assert_driver_not_called() { 2.0.0-preview.1 \ 2.0.0-alpha \ 2.0.0-alpha.1+build.1 \ - 2.0.1 \ - 2.1.0 \ + 2.00.0 \ + 2.0.01 \ + 2.01.0 \ + 2.1.0-rc.0 \ + 2.1.0-rc.01 \ + 2.1.0+build.1 \ 3.0.0; do bats_run "$RELEASE_SCRIPT" check --version "$version" @@ -122,6 +126,32 @@ assert_driver_not_called() { assert_driver_not_called } +@test "release guard delegates post-GA patch and minor workflows" { + local version command + + for version in 2.0.1 2.1.0; do + bats_run "$RELEASE_SCRIPT" refs --version "$version" + [ "$status" -eq 0 ] + [[ "$output" == *"v$version is absent locally and on origin"* ]] + + for command in check plan notes; do + rm -f "$RELEASE_CAPTURE" + bats_run "$RELEASE_SCRIPT" "$command" --version "$version" + [ "$status" -eq 0 ] + grep -Fx "arg=<$command>" "$RELEASE_CAPTURE" + grep -Fx "arg=<$version>" "$RELEASE_CAPTURE" + done + + rm -f "$RELEASE_CAPTURE" + bats_run "$RELEASE_SCRIPT" publish --version "$version" --dry-run + [ "$status" -eq 0 ] + grep -Fx 'arg=' "$RELEASE_CAPTURE" + grep -Fx "arg=<$version>" "$RELEASE_CAPTURE" + grep -Fx 'arg=<--dry-run>' "$RELEASE_CAPTURE" + [ ! -e "$RELEASE_PUBLISH_MARKER" ] + done +} + @test "release guard delegates real prerelease publication after the artifact gate" { bats_run "$RELEASE_SCRIPT" publish --version 2.0.0-beta.1 --yes diff --git a/tests/validate.sh b/tests/validate.sh index 916239a..aa937fb 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -44,6 +44,7 @@ required_files=( bin/base-bash scripts/release scripts/release-artifact + scripts/release-version-policy.sh scripts/api-manifest scripts/library-bundle scripts/vendor @@ -215,10 +216,12 @@ if ! printf '%s\n' "$readme_head" | grep -F "[![Bash](https://img.shields.io/bad exit 1 fi -if [[ ! "$version" =~ ^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$ ]]; then - printf 'VERSION is not a SemVer-compatible version: %s\n' "$version" >&2 +# shellcheck source=../scripts/release-version-policy.sh +source "$repo_root/scripts/release-version-policy.sh" || exit 1 +version_kind="$(base_bash_release_version_kind "$version")" || { + printf 'VERSION is outside the supported Base Bash v2 release policy: %s\n' "$version" >&2 exit 1 -fi +} version_core="${version%%[-+]*}" latest_tag_core="0.0.0" @@ -318,7 +321,7 @@ if [[ "$release_status" == pending-ga-asset ]]; then printf 'README.md must not link to the unpublished v2.0.0 release.\n' >&2 exit 1 fi - elif [[ "$version" =~ ^2[.]0[.]0-(alpha|beta|rc)[.][1-9][0-9]*$ ]]; then + elif [[ "$version_kind" == prerelease ]]; then if ! printf '%s\n' "$readme_head" | grep -F "v$version" > /dev/null; then printf 'README.md must link to the published prerelease candidate v%s.\n' "$version" >&2 exit 1