diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3effc1b..24b8c4a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,23 @@ jobs: - name: Validate repository run: ./tests/validate.sh + - name: Install pinned SPDX validator + run: | + python3 -m venv "$RUNNER_TEMP/spdx-tools" + "$RUNNER_TEMP/spdx-tools/bin/python" -m pip install --disable-pip-version-check 'spdx-tools==0.8.5' + + - name: Validate SPDX release artifact independently + run: | + output="$RUNNER_TEMP/spdx-validation-artifact" + scripts/release-artifact build \ + --version 2.2.0 \ + --commit "$(git rev-parse --verify 'HEAD^{commit}')" \ + --output "$output" + "$RUNNER_TEMP/spdx-tools/bin/python" tests/validate-spdx.py \ + "$output/base-bash-libs-v2.2.0.spdx.json" + "$RUNNER_TEMP/spdx-tools/bin/python" tests/validate-spdx.py \ + --expect-missing-sha1 tests/fixtures/spdx/missing-sha1.spdx.json + - name: Smoke supported runner Bash run: | bash -c ' diff --git a/docs/release-process.md b/docs/release-process.md index 0f6e01e..4de906b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -111,6 +111,18 @@ policy](https://github.com/basefoundry/base/blob/main/docs/ecosystem-policy.md). 9. Verify the annotated `vX.Y.Z` tag and the GitHub Release for `basefoundry/base-bash-libs`. +## SBOM Interoperability + +The SPDX 2.3 SBOM format is versioned with the release: releases before v2.2.0 +retain their published SHA256-only file records, while v2.2.0 and later include +both SHA256 and the SHA1 checksum required by SPDX 2.3 file records. The Linux +and macOS validation jobs independently parse and semantically validate each +candidate SBOM with `spdx-tools==0.8.5`; macOS also exercises the `shasum -a 1` +checksum path. Both jobs check a parseable negative fixture that must produce +exactly one semantic error, specifically the missing SHA1 checksum. This +validation does not rewrite or replace any already-published release asset; +corrected SBOMs are produced only for a future authorized release. + ## Homebrew Handoff The release contract requires the tap-owned formula diff --git a/scripts/release-artifact b/scripts/release-artifact index 889694e..7a01a16 100755 --- a/scripts/release-artifact +++ b/scripts/release-artifact @@ -45,6 +45,14 @@ hash_file() { fi } +hash_file_sha1() { + if command -v sha1sum > /dev/null 2>&1; then + sha1sum -- "$1" | awk '{print $1}' + else + shasum -a 1 -- "$1" | awk '{print $1}' + fi +} + json_escape() { local value="${1-}" value=${value//\\/\\\\} @@ -139,8 +147,25 @@ normalize_tree_times() { done < <(find "$root" -type d -print) } +spdx_sbom_requires_sha1() { + local version="${1-}" major minor version_tail + + major="${version%%.*}" + version_tail="${version#*.}" + minor="${version_tail%%.*}" + [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]] || return 2 + ((10#$major > 2 || (10#$major == 2 && 10#$minor >= 2))) +} + write_sbom() { local output="$1" version="$2" commit="$3" root="$4" path relative first=1 + local include_sha1=0 status file_id file_name sha1_hash sha256_hash + if spdx_sbom_requires_sha1 "$version"; then + include_sha1=1 + else + status=$? + ((status == 1)) || return "$status" + fi { printf '{\n' printf ' "spdxVersion": "SPDX-2.3",\n' @@ -159,8 +184,17 @@ write_sbom() { printf ',\n' fi first=0 - printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ - "$(spdx_file_identifier "$relative")" "$(json_escape "$relative")" "$(hash_file "$path")" + file_id="$(spdx_file_identifier "$relative")" || return 1 + file_name="$(json_escape "$relative")" + sha256_hash="$(hash_file "$path")" || return 1 + if ((include_sha1)); then + sha1_hash="$(hash_file_sha1 "$path")" || return 1 + printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA1", "checksumValue": "%s"}, {"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ + "$file_id" "$file_name" "$sha1_hash" "$sha256_hash" + else + printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ + "$file_id" "$file_name" "$sha256_hash" + fi done < <(find "$root" -type f -print | LC_ALL=C sort) printf '\n ],\n' printf ' "relationships": [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package"}]\n' diff --git a/tests/fixtures/spdx/missing-sha1.spdx.json b/tests/fixtures/spdx/missing-sha1.spdx.json new file mode 100644 index 0000000..cc8631e --- /dev/null +++ b/tests/fixtures/spdx/missing-sha1.spdx.json @@ -0,0 +1,41 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "base-bash-libs-semantic-negative-fixture", + "documentNamespace": "https://example.invalid/base-bash-libs/semantic-negative-fixture", + "creationInfo": { + "created": "2026-01-01T00:00:00Z", + "creators": ["Tool: base-bash-libs-test"] + }, + "packages": [ + { + "SPDXID": "SPDXRef-Package", + "name": "fixture", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": true, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION" + } + ], + "files": [ + { + "SPDXID": "SPDXRef-File-fixture", + "fileName": "fixture.sh", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "licenseConcluded": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package" + } + ] +} diff --git a/tests/release-artifact.bats b/tests/release-artifact.bats index 01cf9e0..68275b2 100644 --- a/tests/release-artifact.bats +++ b/tests/release-artifact.bats @@ -94,29 +94,50 @@ EOF @test "release artifact build creates a deterministic verified asset set" { local first="$TEST_TMPDIR/first" second="$TEST_TMPDIR/second" - bats_run "$RELEASE_ARTIFACT" build --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$first" + bats_run "$RELEASE_ARTIFACT" build --version 2.2.0-rc.1 --commit "$RELEASE_COMMIT" --output "$first" [ "$status" -eq 0 ] bats_run "$RELEASE_ARTIFACT" verify "$first" [ "$status" -eq 0 ] [[ "$output" == *"verified"* ]] - bats_run "$RELEASE_ARTIFACT" build --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$second" + bats_run "$RELEASE_ARTIFACT" build --version 2.2.0-rc.1 --commit "$RELEASE_COMMIT" --output "$second" [ "$status" -eq 0 ] diff -ru "$first" "$second" grep -F '"spdxVersion": "SPDX-2.3"' "$first"/*.spdx.json + grep -F '"algorithm": "SHA1"' "$first"/*.spdx.json + grep -F '"algorithm": "SHA256"' "$first"/*.spdx.json + awk '/"SPDXID": "SPDXRef-File-/ { count++; if ($0 !~ /"algorithm": "SHA1"/ || $0 !~ /"algorithm": "SHA256"/) missing++ } END { exit (count > 0 && missing == 0 ? 0 : 1) }' \ + "$first"/*.spdx.json grep -F '"reproducible": true' "$first"/*.provenance.json awk '/"SPDXID": "SPDXRef-File-/ { id=$0; sub(/^.*"SPDXID": "/, "", id); sub(/".*$/, "", id); if (id !~ /^SPDXRef-File-[A-Za-z0-9.-]+$/ || seen[id]++) exit 1; count++ } END { exit (count > 0 ? 0 : 1) }' \ "$first"/*.spdx.json } +@test "legacy 2.1 SBOM verification retains its SHA256-only file records" { + local artifact="$TEST_TMPDIR/legacy" sbom verified="$TEST_TMPDIR/verified" + + release_test_build "$artifact" 2.1.0 + sbom="$artifact/base-bash-libs-v2.1.0.spdx.json" + awk '/"SPDXID": "SPDXRef-File-/ { count++; if ($0 ~ /"algorithm": "SHA1"/ || $0 !~ /"algorithm": "SHA256"/) invalid++ } END { exit (count > 0 && invalid == 0 ? 0 : 1) }' \ + "$sbom" + + export REMOTE_VERSION=2.1.0 REMOTE_COMMIT="$RELEASE_COMMIT" REMOTE_SOURCE="$artifact" + export REMOTE_TAG_OBJECT=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb REMOTE_ASSET_MODE=full + release_test_make_remote_gh_stub + bats_run "$RELEASE_ARTIFACT" verify-remote --version "$REMOTE_VERSION" \ + --commit "$REMOTE_COMMIT" --output "$verified" + [ "$status" -eq 0 ] + [[ "$output" == *"GitHub Release assets verified"* ]] +} + @test "release artifact bytes are independent of the host timezone" { local utc="$TEST_TMPDIR/utc" local_tz="$TEST_TMPDIR/local-tz" bats_run env TZ=UTC0 TZDIR="$TEST_TMPDIR/missing-zoneinfo" "$RELEASE_ARTIFACT" build \ - --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$utc" + --version 2.2.0-rc.1 --commit "$RELEASE_COMMIT" --output "$utc" [ "$status" -eq 0 ] bats_run env TZ=Asia/Kolkata "$RELEASE_ARTIFACT" build \ - --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$local_tz" + --version 2.2.0-rc.1 --commit "$RELEASE_COMMIT" --output "$local_tz" [ "$status" -eq 0 ] diff -ru "$utc" "$local_tz" diff --git a/tests/validate-spdx.py b/tests/validate-spdx.py new file mode 100644 index 0000000..b8835a5 --- /dev/null +++ b/tests/validate-spdx.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Validate SPDX JSON with the pinned independent spdx-tools package.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from spdx_tools.spdx.parser.parse_anything import parse_file +from spdx_tools.spdx.validation.document_validator import validate_full_spdx_document + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("document", type=Path) + parser.add_argument( + "--expect-missing-sha1", + action="store_true", + help="pass only when semantic validation rejects a file without SHA1", + ) + arguments = parser.parse_args() + + try: + document = parse_file(str(arguments.document)) + except Exception as error: # parser errors are distinct from semantic errors + print(f"SPDX parse failed for {arguments.document}: {error}", file=sys.stderr) + return 1 + + messages = validate_full_spdx_document(document, spdx_version="SPDX-2.3") + if arguments.expect_missing_sha1: + sha1_errors = [ + message + for message in messages + if "must contain a SHA1 algorithm checksum" in message.validation_message + ] + if len(messages) == 1 and len(sha1_errors) == 1: + print("SPDX semantic-negative fixture rejected for its missing required SHA1 checksum.") + return 0 + print( + "SPDX semantic-negative fixture must produce exactly one validation error, for its missing SHA1 checksum.", + file=sys.stderr, + ) + for message in messages: + print(message, file=sys.stderr) + return 1 + + if messages: + print(f"SPDX validation failed for {arguments.document}:", file=sys.stderr) + for message in messages: + print(message, file=sys.stderr) + return 1 + + print(f"SPDX 2.3 document is semantically valid: {arguments.document}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())