diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b9c5e64..89bbedd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,6 +79,13 @@ jobs: with: persist-credentials: false + - name: Build Bash 4.2 release-artifact fixture + run: | + scripts/release-artifact build \ + --version 2.0.0 \ + --commit "$(git rev-parse --verify 'HEAD^{commit}')" \ + --output "$RUNNER_TEMP/base-bash-42-release-artifact" + - name: Run Bash 4.2 compatibility smokes run: | docker run --rm \ @@ -92,7 +99,9 @@ jobs: --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 \ --env HOME=/tmp \ --env TMPDIR=/tmp \ + --env BASE_BASH_RELEASE_ARTIFACT_FIXTURE=/release-artifact \ --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ + --mount "type=bind,src=$RUNNER_TEMP/base-bash-42-release-artifact,dst=/release-artifact,readonly" \ --workdir /workspace \ docker.io/library/bash@sha256:0931edd3941d0603cb3d5da1cb298cf3eb6a579e09e094c3e34e2d5e9df8cddc \ bash -c ' diff --git a/CHANGELOG.md b/CHANGELOG.md index 149713b..255557b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and versions are tracked in the repo-root `VERSION` file. ## [Unreleased] +### Security + +- Bound offline release verification to one complete asset set by requiring + strict checksum coverage and consistent archive, provenance, SBOM, version, + and source-commit identity. + ### Fixed - Eliminated an intermittent macOS Bash process-group race in supervised diff --git a/docs/release-process.md b/docs/release-process.md index e7d352b..f6b4031 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -32,7 +32,13 @@ delegating safe operations to Base's generic release machinery. embeds `lib/bash/base-bash-libs.release` with the exact release version, tag commit, `dirty_state=clean`, and `provenance=release-artifact`; this metadata is generated in the artifact rather than committed with a - self-referential commit hash. + self-referential commit hash. Offline verification requires exactly one + same-version archive, SBOM, provenance statement, and checksum manifest. + The checksum manifest must cover every mandatory asset exactly once, while + the provenance subject, SBOM namespace/package, and embedded metadata must + agree on the archive digest, version, and full source commit. Treat any + missing, duplicate, traversal-bearing, or inconsistent record as a failed + release gate; do not choose one asset from an ambiguous directory. 5. Run the full library validation and inspect the diff: ```bash diff --git a/scripts/release-artifact b/scripts/release-artifact index aee8a35..2c23a56 100755 --- a/scripts/release-artifact +++ b/scripts/release-artifact @@ -52,6 +52,20 @@ commit_is_full_sha() { [[ "${1-}" =~ ^[[:xdigit:]]{40}$ ]] } +file_contains_once() { + local path="$1" value="$2" count + count="$(LC_ALL=C grep -oF -- "$value" "$path" | wc -l | tr -d '[:space:]')" + [[ "$count" == 1 ]] +} + +metadata_value() { + local path="$1" key="$2" + local -a values=() + mapfile -t values < <(sed -n "s/^${key}=//p" "$path") + ((${#values[@]} == 1)) || return 1 + printf '%s' "${values[0]}" +} + repo_is_clean() { local status status="$(git -C "$repo_root" status --porcelain 2> /dev/null)" || return 1 @@ -286,21 +300,75 @@ build_artifact() { } verify_artifact() { - local output="${1-}" archive sums sbom provenance expected path actual temp root_name + local output="${1-}" archive sums sbom provenance version + local archive_name sums_name sbom_name provenance_name selected + local expected path actual line checksum_pattern line_number=0 temp root_name entry + local provenance_subject provenance_parameters provenance_dependency provenance_reproducible + local sbom_name_record sbom_namespace sbom_package + local embedded_version bundle_version bundle_commit release_version release_commit archive_sha + local -a archives=() sums_files=() sboms=() provenances=() required_paths=() + local -A checksum_seen=() [[ -d "$output" ]] || { error "Artifact directory does not exist: $output" return 2 } - archive="$(find "$output" -type f -name 'base-bash-libs-v*.tar.gz' -print | sed -n '1p')" - sbom="$(find "$output" -type f -name 'base-bash-libs-v*.spdx.json' -print | sed -n '1p')" - provenance="$(find "$output" -type f -name 'base-bash-libs-v*.provenance.json' -print | sed -n '1p')" - sums="$(find "$output" -type f -name 'base-bash-libs-v*.SHA256SUMS' -print | sed -n '1p')" - [[ -f "$archive" && -f "$sbom" && -f "$provenance" && -f "$sums" ]] || { - error 'Artifact directory must contain archive, SBOM, provenance, and checksum assets.' + output="$(cd -- "$output" && pwd -P)" || return 2 + mapfile -t archives < <(find "$output" -name 'base-bash-libs-v*.tar.gz' -print) + mapfile -t sboms < <(find "$output" -name 'base-bash-libs-v*.spdx.json' -print) + mapfile -t provenances < <(find "$output" -name 'base-bash-libs-v*.provenance.json' -print) + mapfile -t sums_files < <(find "$output" -name 'base-bash-libs-v*.SHA256SUMS' -print) + if ((${#archives[@]} != 1 || ${#sboms[@]} != 1 || ${#provenances[@]} != 1 || ${#sums_files[@]} != 1)); then + error 'Artifact directory must contain exactly one archive, SBOM, provenance, and checksum asset.' + return 1 + fi + archive="${archives[0]}" + sbom="${sboms[0]}" + provenance="${provenances[0]}" + sums="${sums_files[0]}" + for selected in "$archive" "$sbom" "$provenance" "$sums"; do + [[ "${selected%/*}" == "$output" && ! -L "$selected" ]] || { + error 'Canonical release assets must be regular files in the artifact directory root.' + return 1 + } + done + + archive_name="${archive##*/}" + version="${archive_name#base-bash-libs-v}" + version="${version%.tar.gz}" + version_is_supported "$version" || { + error "Archive version is not supported: $version" + return 1 + } + sbom_name="base-bash-libs-v$version.spdx.json" + provenance_name="base-bash-libs-v$version.provenance.json" + sums_name="base-bash-libs-v$version.SHA256SUMS" + [[ "${sbom##*/}" == "$sbom_name" && "${provenance##*/}" == "$provenance_name" && "${sums##*/}" == "$sums_name" ]] || { + error 'Archive, SBOM, provenance, and checksum filenames do not identify the same version.' return 1 } - while read -r expected path; do - [[ -n "$expected" && -n "$path" ]] || continue + + required_paths=("$archive_name" "$sbom_name" "$provenance_name") + checksum_pattern='^([0-9a-f]{64}) ([^[:space:]/]+)$' + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + [[ "$line" =~ $checksum_pattern ]] || { + error "Malformed checksum entry at line $line_number." + return 1 + } + expected="${BASH_REMATCH[1]}" + path="${BASH_REMATCH[2]}" + case "$path" in + "$archive_name" | "$sbom_name" | "$provenance_name") ;; + *) + error "Unexpected or unsafe checksum entry: $path" + return 1 + ;; + esac + [[ -z "${checksum_seen[$path]+present}" ]] || { + error "Duplicate checksum entry: $path" + return 1 + } + checksum_seen[$path]=1 [[ -f "$output/$path" ]] || { error "Checksum entry is missing: $path" return 1 @@ -311,12 +379,50 @@ verify_artifact() { return 1 } done < "$sums" - grep -F '"reproducible": true' "$provenance" > /dev/null || { - error 'Provenance does not declare a reproducible build.' + for path in "${required_paths[@]}"; do + [[ -n "${checksum_seen[$path]+present}" ]] || { + error "Checksum manifest does not cover mandatory asset: $path" + return 1 + } + done + ((${#checksum_seen[@]} == ${#required_paths[@]})) || { + error 'Checksum manifest does not describe exactly the mandatory release assets.' + return 1 + } + + archive_sha="$(hash_file "$archive")" || return 1 + provenance_subject=" \"subject\": [{\"name\": \"$archive_name\", \"digest\": {\"sha256\": \"$archive_sha\"}}]," + provenance_parameters=" \"externalParameters\": {\"version\": \"$version\", \"sourceCommit\": \"" + provenance_dependency=' "resolvedDependencies": [{"uri": "git+https://github.com/basefoundry/base-bash-libs.git", "digest": {"sha1": "' + provenance_reproducible=' "runDetails": {"builder": {"id": "base-bash-libs/release-artifact"}, "metadata": {"reproducible": true}}' + file_contains_once "$provenance" "$provenance_subject" || { + error 'Provenance subject does not bind to the selected archive and digest.' return 1 } - grep -F '"spdxVersion": "SPDX-2.3"' "$sbom" > /dev/null || { - error 'SBOM is not SPDX 2.3.' + file_contains_once "$provenance" "$provenance_parameters" || { + error 'Provenance does not identify the selected release version exactly once.' + return 1 + } + file_contains_once "$provenance" '"sourceCommit":' || { + error 'Provenance source commit is missing or ambiguous.' + return 1 + } + file_contains_once "$provenance" "$provenance_dependency" || { + error 'Provenance source dependency is missing or ambiguous.' + return 1 + } + file_contains_once "$provenance" "$provenance_reproducible" || { + error 'Provenance does not declare one canonical reproducible build.' + return 1 + } + + sbom_name_record=" \"name\": \"base-bash-libs-v$version-sbom\"," + file_contains_once "$sbom" '"spdxVersion": "SPDX-2.3"' || { + error 'SBOM does not identify one SPDX 2.3 document.' + return 1 + } + file_contains_once "$sbom" "$sbom_name_record" || { + error 'SBOM name does not identify the selected release version.' return 1 } temp="$(mktemp -d "${TMPDIR:-/tmp}/base-bash-release-verify.XXXXXX")" || return 1 @@ -325,26 +431,117 @@ verify_artifact() { error 'Unable to list release archive.' return 1 fi - if grep -E '(^/|(^|/)\.\.?/)' "$temp/list" > /dev/null; then + if ! tar -tvzf "$archive" > "$temp/verbose-list" || grep -Ev '^-' "$temp/verbose-list" > /dev/null; then + rm -rf -- "$temp" + error 'Release archive must contain only regular files.' + return 1 + fi + if grep -E '(^/|(^|/)\.\.?(/|$))' "$temp/list" > /dev/null; then rm -rf -- "$temp" error 'Release archive contains an unsafe path.' return 1 fi root_name="$(sed -n '1s#/.*##p' "$temp/list")" - [[ -n "$root_name" ]] || { + [[ "$root_name" == "base-bash-libs-v$version" ]] || { rm -rf -- "$temp" - error 'Release archive has no root directory.' + error 'Release archive root does not identify the selected version.' return 1 } + while IFS= read -r entry || [[ -n "$entry" ]]; do + [[ "$entry" == "$root_name/"* ]] || { + rm -rf -- "$temp" + error 'Release archive contains more than one root or an unsafe member.' + return 1 + } + done < "$temp/list" + if [[ -n "$(LC_ALL=C sort "$temp/list" | uniq -d | sed -n '1p')" ]]; then + rm -rf -- "$temp" + error 'Release archive contains a duplicate member.' + return 1 + fi tar -xzf "$archive" -C "$temp" || { rm -rf -- "$temp" return 1 } + [[ -d "$temp/$root_name" && ! -L "$temp/$root_name" ]] || { + rm -rf -- "$temp" + error 'Release archive root is not a regular directory.' + return 1 + } "$repo_root/scripts/library-bundle" verify "$temp/$root_name" > /dev/null || { rm -rf -- "$temp" error 'Embedded bundle verification failed.' return 1 } + [[ -f "$temp/$root_name/VERSION" ]] || { + rm -rf -- "$temp" + error 'Embedded release version metadata is missing.' + return 1 + } + embedded_version="$(< "$temp/$root_name/VERSION")" + bundle_version="$(metadata_value "$temp/$root_name/BUNDLE.release" source_version)" || bundle_version='' + bundle_commit="$(metadata_value "$temp/$root_name/BUNDLE.release" source_commit)" || bundle_commit='' + release_version="$(metadata_value "$temp/$root_name/lib/bash/base-bash-libs.release" version)" || release_version='' + release_commit="$(metadata_value "$temp/$root_name/lib/bash/base-bash-libs.release" commit)" || release_commit='' + [[ "$embedded_version" == "$version" && "$bundle_version" == "$version" && "$release_version" == "$version" ]] || { + rm -rf -- "$temp" + error 'Version identity disagrees across filenames and embedded release metadata.' + return 1 + } + [[ -n "$bundle_commit" && "$bundle_commit" == "$release_commit" ]] || { + rm -rf -- "$temp" + error 'Source commit identity disagrees across embedded release metadata.' + return 1 + } + commit_is_full_sha "$bundle_commit" || { + rm -rf -- "$temp" + error 'Embedded source commit is not a full SHA.' + return 1 + } + provenance_parameters="${provenance_parameters}${bundle_commit}\", \"dirtyState\": \"clean\"}," + provenance_dependency="${provenance_dependency}${bundle_commit}\"}}]" + file_contains_once "$provenance" "$provenance_parameters" || { + rm -rf -- "$temp" + error 'Provenance version and source commit disagree with embedded release metadata.' + return 1 + } + file_contains_once "$provenance" "$provenance_dependency" || { + rm -rf -- "$temp" + error 'Provenance dependency digest disagrees with embedded release metadata.' + return 1 + } + sbom_namespace=" \"documentNamespace\": \"https://github.com/basefoundry/base-bash-libs/releases/v$version/$bundle_commit\"," + sbom_package=" \"packages\": [{\"SPDXID\": \"SPDXRef-Package\", \"name\": \"base-bash-libs\", \"versionInfo\": \"$version\", \"downloadLocation\": \"https://github.com/basefoundry/base-bash-libs/releases/tag/v$version\", \"licenseConcluded\": \"Apache-2.0\", \"licenseDeclared\": \"Apache-2.0\"}]," + file_contains_once "$sbom" "$sbom_namespace" || { + rm -rf -- "$temp" + error 'SBOM namespace disagrees with the release version or source commit.' + return 1 + } + file_contains_once "$sbom" "$sbom_package" || { + rm -rf -- "$temp" + error 'SBOM package identity disagrees with the selected release version.' + return 1 + } + write_provenance "$temp/expected.provenance.json" "$version" "$bundle_commit" "$archive_name" "$archive_sha" || { + rm -rf -- "$temp" + error 'Unable to reconstruct canonical release provenance.' + return 1 + } + cmp -s -- "$provenance" "$temp/expected.provenance.json" || { + rm -rf -- "$temp" + error 'Provenance statement is not the canonical record for the selected artifact.' + return 1 + } + write_sbom "$temp/expected.spdx.json" "$version" "$bundle_commit" "$temp/$root_name" || { + rm -rf -- "$temp" + error 'Unable to reconstruct the canonical release SBOM.' + return 1 + } + cmp -s -- "$sbom" "$temp/expected.spdx.json" || { + rm -rf -- "$temp" + error 'SBOM is not the canonical record for the selected artifact.' + return 1 + } rm -rf -- "$temp" printf 'Canonical release artifact set verified: %s\n' "$output" } diff --git a/tests/bash-42-release-smoke.sh b/tests/bash-42-release-smoke.sh index a0c2e33..8433eac 100755 --- a/tests/bash-42-release-smoke.sh +++ b/tests/bash-42-release-smoke.sh @@ -45,7 +45,8 @@ git() { main() { local expected_major="${1-}" expected_minor="${2-}" expected_patch="${3-}" - local script_dir repo_root release_script release_driver capture_path output_path + local script_dir repo_root release_script release_driver release_artifact + local capture_path output_path artifact_output source_commit artifact_fixture if (($# != 0 && $# != 3)); then release_smoke_fail "usage: $0 [expected-major expected-minor expected-patch]" @@ -94,8 +95,7 @@ main() { "$release_script" check --version 1.5.0 || return 1 rm -f -- "$capture_path" - "$release_script" publish --version 2.0.0 --yes > "$output_path" 2>&1 - if (($? != 0)); then + if ! "$release_script" publish --version 2.0.0 --yes > "$output_path" 2>&1; then release_smoke_fail "GA release command was not delegated." return 1 fi @@ -106,7 +106,33 @@ main() { 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" + # The release-artifact verifier is part of the minimum-Bash trust boundary, + # not only a modern-host validation path. CI mounts a fixture built by the + # host because the pinned, networkless Bash 4.2 image intentionally has no + # Git. Developer hosts may build the same fixture directly. + unset -f git + release_artifact="$repo_root/scripts/release-artifact" + artifact_fixture="${BASE_BASH_RELEASE_ARTIFACT_FIXTURE-}" + if [[ -n "$artifact_fixture" ]]; then + artifact_output="$artifact_fixture" + else + artifact_output="$release_smoke_dir/artifact" + source_commit="$(command git -C "$repo_root" rev-parse --verify 'HEAD^{commit}' 2> /dev/null)" || { + release_smoke_fail "unable to resolve the source commit for artifact verification." + return 1 + } + if ! "$release_artifact" build --version 2.0.0 --commit "$source_commit" \ + --output "$artifact_output" > "$output_path" 2>&1; then + release_smoke_fail "canonical artifact build failed on Bash $BASH_VERSION." + return 1 + fi + fi + if ! "$release_artifact" verify "$artifact_output" > "$output_path" 2>&1; then + release_smoke_fail "canonical artifact verification failed on Bash $BASH_VERSION." + return 1 + fi + + printf 'Bash release and artifact smoke passed on Bash %s.\n' "$BASH_VERSION" return 0 } diff --git a/tests/release-artifact.bats b/tests/release-artifact.bats index deb616c..6026256 100644 --- a/tests/release-artifact.bats +++ b/tests/release-artifact.bats @@ -8,6 +8,30 @@ setup() { RELEASE_COMMIT="$(git -C "$BASE_REPO_ROOT" rev-parse HEAD)" } +release_test_hash_file() { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum -- "$1" | awk '{print $1}' + else + shasum -a 256 -- "$1" | awk '{print $1}' + fi +} + +release_test_refresh_checksum() { + local asset="$1" sums="$2" name hash temporary + name="${asset##*/}" + hash="$(release_test_hash_file "$asset")" + temporary="$TEST_TMPDIR/checksums.tmp" + awk -v name="$name" -v hash="$hash" ' + $2 == name { $1 = hash } + { printf "%s %s\n", $1, $2 } + ' "$sums" > "$temporary" + mv -- "$temporary" "$sums" +} + +release_test_build() { + "$RELEASE_ARTIFACT" build --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$1" > /dev/null +} + @test "release artifact build creates a deterministic verified asset set" { local first="$TEST_TMPDIR/first" second="$TEST_TMPDIR/second" @@ -33,3 +57,103 @@ setup() { [ "$status" -eq 1 ] [[ "$output" == *"Checksum mismatch"* ]] } + +@test "release artifact verification requires checksum coverage for every mandatory asset" { + local artifact="$TEST_TMPDIR/artifact" sums provenance temporary + + release_test_build "$artifact" + sums="$artifact/base-bash-libs-v2.0.0-rc.1.SHA256SUMS" + provenance="base-bash-libs-v2.0.0-rc.1.provenance.json" + temporary="$TEST_TMPDIR/checksums.tmp" + awk -v name="$provenance" '$2 != name { printf "%s %s\n", $1, $2 }' "$sums" > "$temporary" + mv -- "$temporary" "$sums" + + bats_run "$RELEASE_ARTIFACT" verify "$artifact" + [ "$status" -eq 1 ] + [[ "$output" == *"does not cover mandatory asset"* ]] +} + +@test "release artifact verification rejects duplicate and unsafe checksum records" { + local artifact="$TEST_TMPDIR/artifact" sums first_line temporary + + release_test_build "$artifact" + sums="$artifact/base-bash-libs-v2.0.0-rc.1.SHA256SUMS" + first_line="$(sed -n '1p' "$sums")" + printf '%s\n' "$first_line" >> "$sums" + bats_run "$RELEASE_ARTIFACT" verify "$artifact" + [ "$status" -eq 1 ] + [[ "$output" == *"Duplicate checksum entry"* ]] + + release_test_build "$TEST_TMPDIR/unsafe" + sums="$TEST_TMPDIR/unsafe/base-bash-libs-v2.0.0-rc.1.SHA256SUMS" + temporary="$TEST_TMPDIR/checksums.tmp" + awk 'NR == 1 { $2 = "../" $2 } { printf "%s %s\n", $1, $2 }' "$sums" > "$temporary" + mv -- "$temporary" "$sums" + bats_run "$RELEASE_ARTIFACT" verify "$TEST_TMPDIR/unsafe" + [ "$status" -eq 1 ] + [[ "$output" == *"Malformed checksum entry"* || "$output" == *"unsafe checksum entry"* ]] +} + +@test "release artifact verification binds provenance to the archive and embedded commit" { + local artifact="$TEST_TMPDIR/artifact" provenance sums temporary + + release_test_build "$artifact" + provenance="$artifact/base-bash-libs-v2.0.0-rc.1.provenance.json" + sums="$artifact/base-bash-libs-v2.0.0-rc.1.SHA256SUMS" + temporary="$TEST_TMPDIR/provenance.tmp" + sed 's/"sourceCommit": "[[:xdigit:]]\{40\}"/"sourceCommit": "0000000000000000000000000000000000000000"/' \ + "$provenance" > "$temporary" + mv -- "$temporary" "$provenance" + release_test_refresh_checksum "$provenance" "$sums" + + bats_run "$RELEASE_ARTIFACT" verify "$artifact" + [ "$status" -eq 1 ] + [[ "$output" == *"Provenance version and source commit disagree"* ]] + + release_test_build "$TEST_TMPDIR/subject" + provenance="$TEST_TMPDIR/subject/base-bash-libs-v2.0.0-rc.1.provenance.json" + sums="$TEST_TMPDIR/subject/base-bash-libs-v2.0.0-rc.1.SHA256SUMS" + temporary="$TEST_TMPDIR/provenance.tmp" + sed 's/"sha256": "[[:xdigit:]]\{64\}"/"sha256": "0000000000000000000000000000000000000000000000000000000000000000"/' \ + "$provenance" > "$temporary" + mv -- "$temporary" "$provenance" + release_test_refresh_checksum "$provenance" "$sums" + bats_run "$RELEASE_ARTIFACT" verify "$TEST_TMPDIR/subject" + [ "$status" -eq 1 ] + [[ "$output" == *"subject does not bind"* ]] +} + +@test "release artifact verification binds the SBOM to version and commit identity" { + local artifact="$TEST_TMPDIR/artifact" sbom sums temporary + + release_test_build "$artifact" + sbom="$artifact/base-bash-libs-v2.0.0-rc.1.spdx.json" + sums="$artifact/base-bash-libs-v2.0.0-rc.1.SHA256SUMS" + temporary="$TEST_TMPDIR/sbom.tmp" + sed 's#/releases/v2.0.0-rc.1/[[:xdigit:]]\{40\}"#/releases/v2.0.0-rc.1/0000000000000000000000000000000000000000"#' \ + "$sbom" > "$temporary" + mv -- "$temporary" "$sbom" + release_test_refresh_checksum "$sbom" "$sums" + + bats_run "$RELEASE_ARTIFACT" verify "$artifact" + [ "$status" -eq 1 ] + [[ "$output" == *"SBOM namespace disagrees"* ]] +} + +@test "release artifact verification rejects mismatched versions and ambiguous assets" { + local artifact="$TEST_TMPDIR/artifact" + + release_test_build "$artifact" + mv -- "$artifact/base-bash-libs-v2.0.0-rc.1.spdx.json" \ + "$artifact/base-bash-libs-v2.0.0.spdx.json" + bats_run "$RELEASE_ARTIFACT" verify "$artifact" + [ "$status" -eq 1 ] + [[ "$output" == *"filenames do not identify the same version"* ]] + + release_test_build "$TEST_TMPDIR/ambiguous" + cp -- "$TEST_TMPDIR/ambiguous/base-bash-libs-v2.0.0-rc.1.tar.gz" \ + "$TEST_TMPDIR/ambiguous/base-bash-libs-v2.0.0.tar.gz" + bats_run "$RELEASE_ARTIFACT" verify "$TEST_TMPDIR/ambiguous" + [ "$status" -eq 1 ] + [[ "$output" == *"exactly one archive"* ]] +}