diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 461b978..fb9699b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,11 @@ jobs: - name: Install validation tools run: sudo apt-get update && sudo apt-get install -y bats shellcheck - name: Validate consumer - run: ./tests/validate.sh + run: | + python3 -m venv "$RUNNER_TEMP/spdx" + "$RUNNER_TEMP/spdx/bin/pip" install -r tests/requirements-artifacts.txt + export SPDX_VALIDATOR_PYTHON="$RUNNER_TEMP/spdx/bin/python" + ./tests/validate.sh bash-42: name: Minimum runtime (Bash 4.2.53) @@ -64,6 +68,9 @@ jobs: - name: Validate consumer run: | export PATH="$(brew --prefix)/bin:$PATH" + python3 -m venv "$RUNNER_TEMP/spdx" + "$RUNNER_TEMP/spdx/bin/pip" install -r tests/requirements-artifacts.txt + export SPDX_VALIDATOR_PYTHON="$RUNNER_TEMP/spdx/bin/python" ./tests/validate.sh artifact: @@ -88,4 +95,7 @@ jobs: if [[ "$RUNNER_OS" == "macOS" ]]; then export PATH="$(brew --prefix)/bin:$PATH" fi + python3 -m venv "$RUNNER_TEMP/spdx" + "$RUNNER_TEMP/spdx/bin/pip" install -r tests/requirements-artifacts.txt + export SPDX_VALIDATOR_PYTHON="$RUNNER_TEMP/spdx/bin/python" ./tests/release-artifact.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8fdfd..3558dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and versions are tracked in the repo-root `VERSION` file. ### Fixed +- Emit independently validated SPDX 2.3 file and package evidence with SHA1 and SHA256. + - Reserve bundle destinations exclusively to prevent concurrent publication nesting. - Honor user configuration and scenario precedence without implicit CLI overrides. - Normalize release timestamps in UTC and test cross-timezone reproducibility. diff --git a/docs/release-process.md b/docs/release-process.md index 05619a0..5dabcad 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -11,6 +11,21 @@ release, and a framework pin update does not publish Beacon by itself. ## Standalone Artifact Gate +Artifact tests require the independent pinned SPDX validator. Install it once in +a virtual environment and point the tests at that interpreter: + +```bash +python3 -m venv /tmp/beacon-spdx-validator +/tmp/beacon-spdx-validator/bin/pip install -r tests/requirements-artifacts.txt +export SPDX_VALIDATOR_PYTHON=/tmp/beacon-spdx-validator/bin/python +``` + +The test gate requires zero semantic validation messages from spdx-tools 0.8.5. +The generator uses only Python's standard library and remains offline. SHA1 is +included for SPDX 2.3 interoperability; SHA256 remains the integrity checksum. +Package verification codes and CONTAINS relationships partition application +files and embedded framework files without claiming per-file license analysis. + From the clean release commit, build the four assets without publishing them: ```bash diff --git a/scripts/artifact-evidence.py b/scripts/artifact-evidence.py new file mode 100644 index 0000000..ed1ab2c --- /dev/null +++ b/scripts/artifact-evidence.py @@ -0,0 +1,72 @@ +"""Deterministic Beacon SPDX evidence (standard library only).""" + +import hashlib +import json +from pathlib import Path +import sys + + +def metadata(path): + values = {} + for line in path.read_text().splitlines(): + if "=" in line: + key, value = line.split("=", 1) + if key in values: + raise ValueError("duplicate metadata key") + values[key] = value + return values + + +def sbom(root, version, commit): + lock = metadata(root / "base-bash-libs.lock") + beacon = "SPDXRef-Package-Beacon" + framework = "SPDXRef-Package-BaseBashLibs" + files, relationships = [], [ + {"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": beacon}, + {"spdxElementId": beacon, "relationshipType": "DEPENDS_ON", "relatedSpdxElement": framework}, + ] + hashes = {beacon: [], framework: []} + for index, path in enumerate(sorted(p for p in root.rglob("*") if p.is_file()), 1): + relative = path.relative_to(root).as_posix() + content = path.read_bytes() + sha1 = hashlib.sha1(content).hexdigest() + owner = framework if relative.startswith("vendor/base-bash-libs/") else beacon + hashes[owner].append(sha1) + file_id = f"SPDXRef-File-{index}" + files.append({ + "SPDXID": file_id, "fileName": "./" + relative, + "checksums": [ + {"algorithm": "SHA1", "checksumValue": sha1}, + {"algorithm": "SHA256", "checksumValue": hashlib.sha256(content).hexdigest()}, + ], + "licenseConcluded": "NOASSERTION", + }) + relationships.append({"spdxElementId": owner, "relationshipType": "CONTAINS", "relatedSpdxElement": file_id}) + packages = [] + for identifier, name, release, source in ( + (beacon, "beacon", version, commit), + (framework, "base-bash-libs", lock["version"], lock["commit"]), + ): + repo = "base-bash-libs-demo" if identifier == beacon else name + packages.append({ + "SPDXID": identifier, "name": name, "versionInfo": release, + "downloadLocation": f"https://github.com/basefoundry/{repo}/releases/tag/v{release}", + "filesAnalyzed": True, + "packageVerificationCode": {"packageVerificationCodeValue": hashlib.sha1("".join(sorted(hashes[identifier])).encode()).hexdigest()}, + "licenseConcluded": "Apache-2.0", "licenseDeclared": "Apache-2.0", + "externalRefs": [{"referenceCategory": "OTHER", "referenceType": "source-commit", "referenceLocator": source}], + }) + return { + "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", + "name": f"beacon-v{version}-sbom", + "documentNamespace": f"https://github.com/basefoundry/base-bash-libs-demo/releases/v{version}/{commit}", + "creationInfo": {"created": "1970-01-01T00:00:00Z", "creators": ["Tool: base-bash-libs-demo/release-artifact"]}, + "packages": packages, "files": files, "relationships": relationships, + } + + +if __name__ == "__main__": + if len(sys.argv) != 5 or sys.argv[1] != "sbom": + sys.exit("usage: artifact-evidence.py sbom ROOT VERSION COMMIT") + json.dump(sbom(Path(sys.argv[2]), sys.argv[3], sys.argv[4]), sys.stdout, indent=2) + print() diff --git a/scripts/release-artifact b/scripts/release-artifact index ffc5247..b618116 100755 --- a/scripts/release-artifact +++ b/scripts/release-artifact @@ -96,6 +96,7 @@ artifact_copy_payload() { fixtures/workspace lib/beacon.sh scripts/release-artifact + scripts/artifact-evidence.py scripts/verify-vendor vendor/base-bash-libs vendor/evidence @@ -203,44 +204,7 @@ artifact_create_archive() { } artifact_write_sbom() { - local output="$1" version="$2" source_commit="$3" root="$4" - local framework_version framework_commit path relative first=1 index=0 - - framework_version="$(artifact_metadata_value "$root/base-bash-libs.lock" version)" || return 1 - framework_commit="$(artifact_metadata_value "$root/base-bash-libs.lock" commit)" || return 1 - { - printf '{\n' - printf ' "spdxVersion": "SPDX-2.3",\n' - printf ' "dataLicense": "CC0-1.0",\n' - printf ' "SPDXID": "SPDXRef-DOCUMENT",\n' - printf ' "name": "beacon-v%s-sbom",\n' "$(artifact_json_escape "$version")" - printf ' "documentNamespace": "https://github.com/basefoundry/base-bash-libs-demo/releases/v%s/%s",\n' \ - "$(artifact_json_escape "$version")" "$source_commit" - printf ' "creationInfo": {"created": "1970-01-01T00:00:00Z", "creators": ["Tool: base-bash-libs-demo/release-artifact"]},\n' - printf ' "packages": [\n' - printf ' {"SPDXID": "SPDXRef-Package-Beacon", "name": "beacon", "versionInfo": "%s", "downloadLocation": "https://github.com/basefoundry/base-bash-libs-demo/releases/tag/v%s", "licenseConcluded": "Apache-2.0", "licenseDeclared": "Apache-2.0"},\n' \ - "$(artifact_json_escape "$version")" "$(artifact_json_escape "$version")" - printf ' {"SPDXID": "SPDXRef-Package-BaseBashLibs", "name": "base-bash-libs", "versionInfo": "%s", "downloadLocation": "https://github.com/basefoundry/base-bash-libs/releases/tag/v%s", "licenseConcluded": "Apache-2.0", "licenseDeclared": "Apache-2.0", "externalRefs": [{"referenceCategory": "OTHER", "referenceType": "source-commit", "referenceLocator": "%s"}]}\n' \ - "$(artifact_json_escape "$framework_version")" "$(artifact_json_escape "$framework_version")" "$framework_commit" - printf ' ],\n' - printf ' "files": [\n' - while IFS= read -r path; do - relative="${path#"$root/"}" - ((index += 1)) - if ((first == 0)); then - printf ',\n' - fi - first=0 - printf ' {"SPDXID": "SPDXRef-File-%s", "fileName": "%s", "checksums": [{"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "NOASSERTION"}' \ - "$index" "$(artifact_json_escape "$relative")" "$(artifact_hash_file "$path")" - done < <(find "$root" -type f -print | LC_ALL=C sort) - printf '\n ],\n' - printf ' "relationships": [\n' - printf ' {"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package-Beacon"},\n' - printf ' {"spdxElementId": "SPDXRef-Package-Beacon", "relationshipType": "DEPENDS_ON", "relatedSpdxElement": "SPDXRef-Package-BaseBashLibs"}\n' - printf ' ]\n' - printf '}\n' - } > "$output" + python3 "$artifact_repo_root/scripts/artifact-evidence.py" sbom "$4" "$2" "$3" > "$1" } artifact_write_provenance() { diff --git a/tests/release-artifact.sh b/tests/release-artifact.sh index 7a80a8c..6154e21 100755 --- a/tests/release-artifact.sh +++ b/tests/release-artifact.sh @@ -20,6 +20,8 @@ TZ=Asia/Kolkata "$release_test_root/scripts/release-artifact" build \ --version "$release_test_version" --output "$release_test_second" diff -r "$release_test_first" "$release_test_second" +"${SPDX_VALIDATOR_PYTHON:-python3}" "$release_test_root/tests/validate-spdx.py" \ + "$release_test_first/beacon-v$release_test_version.spdx.json" "$release_test_root/scripts/release-artifact" verify "$release_test_first" if "$release_test_root/scripts/release-artifact" build \ diff --git a/tests/requirements-artifacts.txt b/tests/requirements-artifacts.txt new file mode 100644 index 0000000..af31bb4 --- /dev/null +++ b/tests/requirements-artifacts.txt @@ -0,0 +1 @@ +spdx-tools==0.8.5 diff --git a/tests/validate-spdx.py b/tests/validate-spdx.py new file mode 100644 index 0000000..46add63 --- /dev/null +++ b/tests/validate-spdx.py @@ -0,0 +1,13 @@ +"""Independent SPDX 2.3 semantic validation; required, not an optional gate.""" +import importlib.metadata +import sys + +from spdx_tools.spdx.parser.parse_anything import parse_file +from spdx_tools.spdx.validation.document_validator import validate_full_spdx_document + +if importlib.metadata.version("spdx-tools") != "0.8.5": + sys.exit("Install the pinned tests/requirements-artifacts.txt validator") +messages = validate_full_spdx_document(parse_file(sys.argv[1])) +for message in messages: + print(message, file=sys.stderr) +sys.exit(bool(messages)) diff --git a/tests/validate.sh b/tests/validate.sh index 1c02f2d..2a6638d 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -17,6 +17,9 @@ required_files=( examples/minimal-cli lib/beacon.sh scripts/release-artifact + scripts/artifact-evidence.py + tests/requirements-artifacts.txt + tests/validate-spdx.py scripts/verify-vendor tests/beacon.bats tests/lifecycle.bats