Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 89 additions & 4 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ on:
- "scripts/validate_examples.py"
- "scripts/generate_release_metadata.py"
- "scripts/validate_release_metadata.py"
- "scripts/verify_release_assets.py"
- "scripts/validate_changelog.py"
- "scripts/validate_release_ref.py"
- "CHANGELOG.md"
- "examples/**"
- "compatibility/**"
- "docs/releasing.md"
- "tests/test_package_workflow.py"
- "tests/test_verify_release_assets.py"
- "requirements/release.in"
- "requirements/release.txt"
- ".github/workflows/package.yml"
Expand Down Expand Up @@ -312,7 +315,16 @@ jobs:
timeout-minutes: 10
permissions:
contents: write
attestations: read
steps:
- name: Check out the tag source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

- name: Download reviewed distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
Expand All @@ -325,18 +337,91 @@ jobs:
name: base-cli-release-metadata-${{ github.run_id }}
path: dist

- name: Create GitHub Release
- name: Verify and create immutable GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
assets=(dist/*.whl dist/*.tar.gz dist/SHA256SUMS dist/SBOM.spdx.json dist/RELEASE-BOM-ROW.json)
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Published release $tag already exists; refusing to replace immutable release assets." >&2
tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq .sha)"
if [[ "$tag_commit" != "$GITHUB_SHA" ]]; then
echo "Release tag $tag resolves to $tag_commit, not reviewed commit $GITHUB_SHA." >&2
exit 1
fi

python scripts/validate_release_metadata.py dist
for asset in dist/*.whl dist/*.tar.gz; do
gh attestation verify "$asset" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \
--source-digest "$GITHUB_SHA" \
--source-ref "$GITHUB_REF"
gh attestation verify "$asset" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "github.com/$GITHUB_REPOSITORY/.github/workflows/package.yml" \
--source-digest "$GITHUB_SHA" \
--source-ref "$GITHUB_REF" \
--predicate-type "https://spdx.dev/Document"
done

release_tmp="$(mktemp -d "$RUNNER_TEMP/base-cli-release.XXXXXX")"
trap 'rm -rf "$release_tmp"' EXIT
release_json="$release_tmp/release.json"
release_error="$release_tmp/release-error.txt"

read_release_metadata() {
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" >"$release_json" 2>"$release_error"; then
return 0
fi
if grep -Fq "(HTTP 404)" "$release_error"; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustness: read_release_metadata() distinguishes "release not found" from "real API error" only by grepping stderr for the literal substring (HTTP 404), an undocumented, version-fragile detail of the gh CLI's error text. If a future gh version changes its 404 error format, this falls through to a hard error and would permanently block the first release of a new tag until patched.

return 1
fi
cat "$release_error" >&2
return 2
}

verify_existing_release() {
local existing_assets="$release_tmp/existing-assets"
mkdir -p "$existing_assets"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" --dir "$existing_assets"
python scripts/verify_release_assets.py \
--expected-dir dist \
--existing-dir "$existing_assets" \
--release-json "$release_json" \
--version-file VERSION \
--tag "$tag" \
--source-commit "$GITHUB_SHA" \
--resolved-tag-commit "$tag_commit"
}

if read_release_metadata; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: the post-create read-back has no retry/backoff, so GitHub API read-after-write lag can turn a successful release creation into a spurious job failure — e.g. gh release create succeeds but the immediately-following gh api repos/.../releases/tags/$tag hits a stale replica and returns 404 before the write propagates, causing the job to exit 1 even though the release was created correctly.

verify_existing_release
echo "Existing release $tag is byte-for-byte identical; leaving the immutable release unchanged."
exit 0
else
release_status=$?
if [[ "$release_status" -ne 1 ]]; then
exit "$release_status"
fi
fi

create_status=0
gh release create "$tag" "${assets[@]}" \
--repo "$GITHUB_REPOSITORY" \
--title "$tag" \
--generate-notes \
--notes "Published distributions and release metadata for $tag. See CHANGELOG.md for the reviewed release notes."
--notes "Published distributions and release metadata for $tag. See CHANGELOG.md for the reviewed release notes." \
|| create_status=$?

if read_release_metadata; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: success is inferred solely from the post-create read-back matching bytes, so a genuine failure reported by gh release create (non-zero create_status) is silently discarded whenever the release object still reads back and its assets happen to match. If gh release create fails on a late step (e.g. --generate-notes API hiccup) after uploading all assets, the script would print 'Verified immutable release ... after publication.' and exit 0 — the real failure signal from gh never surfaces.

verify_existing_release
echo "Verified immutable release $tag after publication."
else
release_status=$?
if [[ "$create_status" -ne 0 ]]; then
exit "$create_status"
fi
echo "Release $tag was created but could not be read back (HTTP 404)." >&2
exit "$release_status"
fi
13 changes: 11 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,17 @@ the exact reviewed wheel, sdist, `SHA256SUMS`, `SBOM.spdx.json`, and
comparison notes are supplemented by the
dated section in `CHANGELOG.md`; the tagged release is rejected when `VERSION`
or that section does not match the tag. Published tags and release assets are
immutable. A rerun that finds an existing GitHub Release fails closed;
corrections require a new patch version.
immutable. Before creating a release, the workflow verifies that the tag still
resolves to the reviewed commit, that the wheel and sdist have provenance and
SBOM attestations for that tag and commit, and that the release metadata binds
the same version, commit, and assets. If an existing GitHub Release is found,
the workflow downloads its assets and permits an idempotent rerun only when the
published release is non-draft, has the same tag identity, and every filename
and byte matches the reviewed artifacts. It makes no changes to an identical
release. Any changed, missing, extra, or renamed asset fails with expected and
observed checksums; corrections require a new patch version rather than
overwriting published bytes. A failed first upload that leaves a partial
release must be reviewed and recovered with a new release version.

## Independent verification

Expand Down
182 changes: 182 additions & 0 deletions scripts/verify_release_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Verify that an existing GitHub Release is an exact replay of reviewed assets."""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any

SBOM_NAME = "SBOM.spdx.json"
CHECKSUMS_NAME = "SHA256SUMS"
BOM_ROW_NAME = "RELEASE-BOM-ROW.json"
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
HEX_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")


def _sha256(path: Path) -> str:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reuse: the _sha256 helper and SBOM/BOM/checksum validation logic (lines 96-113) duplicate what already exists in scripts/validate_release_metadata.py (and generate_release_metadata.py), which this same workflow step already invokes on the identical dist/ files. A future rule change made in one script but not the other would let the two silently drift — a release passing pre-creation validation could fail post-creation verification, or vice versa.

digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def _files(directory: Path, label: str, errors: list[str]) -> dict[str, Path]:
if not directory.is_dir():
errors.append(f"{label} directory does not exist: {directory}")
return {}
result: dict[str, Path] = {}
for path in directory.iterdir():
if path.is_symlink() or not path.is_file():
errors.append(f"{label} contains a non-regular asset: {path.name}")
continue
result[path.name] = path
return result


def _read_json(path: Path, label: str, errors: list[str]) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
errors.append(f"{label} is not valid JSON: {exc}")
return {}
if not isinstance(value, dict):
errors.append(f"{label} must be a JSON object")
return {}
return value


def validate_release_assets(
expected_dir: Path,
existing_dir: Path,
release_json: Path,
version_file: Path,
*,
tag: str,
source_commit: str,
resolved_tag_commit: str,
) -> list[str]:
"""Return violations between reviewed files and an existing immutable release."""

errors: list[str] = []
expected = _files(expected_dir, "reviewed artifact", errors)
existing = _files(existing_dir, "existing release", errors)

required_names = {SBOM_NAME, CHECKSUMS_NAME, BOM_ROW_NAME}
wheels = {name for name in expected if name.endswith(".whl")}
sdists = {name for name in expected if name.endswith(".tar.gz")}
if len(wheels) != 1 or len(sdists) != 1 or not required_names.issubset(expected):
errors.append("reviewed artifacts must contain one wheel, one sdist, SHA256SUMS, SBOM, and release BOM row")

try:
version = version_file.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError) as exc:
errors.append(f"could not read package VERSION: {exc}")
version = ""
if tag != f"v{version}" or not version:
errors.append(f"release tag {tag!r} does not match package version {version!r}")
if not SHA_RE.fullmatch(source_commit):
errors.append("source commit must be a lowercase full 40-character SHA")
if resolved_tag_commit != source_commit:
errors.append(
f"release tag resolves to {resolved_tag_commit!r}, not the reviewed source commit {source_commit!r}"
)

release = _read_json(release_json, "existing release metadata", errors)
if release.get("tag_name") != tag:
errors.append(f"existing release tag {release.get('tag_name')!r} does not match {tag!r}")
if release.get("draft") is not False or release.get("prerelease") is not False:
errors.append("an existing release must be published and non-prerelease")

if SBOM_NAME in expected:
sbom = _read_json(expected[SBOM_NAME], SBOM_NAME, errors)
namespace = str(sbom.get("documentNamespace", ""))
if sbom.get("name") != f"base-cli-{version}":
errors.append(f"{SBOM_NAME} does not identify base-cli version {version}")
if f"/sbom/{version}/{source_commit}" not in namespace:
errors.append(f"{SBOM_NAME} namespace is not bound to tag {tag} and commit {source_commit}")
if source_commit not in str(sbom.get("documentComment", "")):
errors.append(f"{SBOM_NAME} comment is not bound to source commit {source_commit}")

if BOM_ROW_NAME in expected:
bom_row = _read_json(expected[BOM_ROW_NAME], BOM_ROW_NAME, errors)
if bom_row.get("repository") != "basefoundry/base-cli":
errors.append(f"{BOM_ROW_NAME} identifies the wrong repository")
if bom_row.get("version") != version or bom_row.get("tag") != tag:
errors.append(f"{BOM_ROW_NAME} version/tag does not match {tag}")
if bom_row.get("commit") != source_commit:
errors.append(f"{BOM_ROW_NAME} is not bound to source commit {source_commit}")

if CHECKSUMS_NAME in expected:
checksum_rows: dict[str, str] = {}
try:
lines = expected[CHECKSUMS_NAME].read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError) as exc:
errors.append(f"{CHECKSUMS_NAME} could not be read: {exc}")
lines = []
for line in lines:
fields = line.split(maxsplit=1)
if len(fields) != 2 or not HEX_SHA256_RE.fullmatch(fields[0]):
errors.append(f"invalid checksum row in {CHECKSUMS_NAME}: {line!r}")
continue
checksum_rows[fields[1]] = fields[0]
binary_names = wheels | sdists
if set(checksum_rows) != binary_names:
errors.append(f"{CHECKSUMS_NAME} must cover exactly the reviewed wheel and sdist")
for name in binary_names & set(expected):
actual = _sha256(expected[name])
if checksum_rows.get(name) != actual:
errors.append(f"{CHECKSUMS_NAME} digest for {name} does not match the reviewed artifact")

expected_names = set(expected)
existing_names = set(existing)
if expected_names != existing_names:
errors.append(
"release asset filenames differ: "
f"expected {sorted(expected_names)!r}, observed {sorted(existing_names)!r}; "
"refusing to replace immutable release assets"
)
for name in sorted(expected_names & existing_names):
expected_digest = _sha256(expected[name])
existing_digest = _sha256(existing[name])
if expected_digest != existing_digest:
errors.append(
f"release asset {name} differs: expected SHA-256 {expected_digest}, "
f"observed SHA-256 {existing_digest}; refusing to replace immutable release assets"
)
return errors


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--expected-dir", type=Path, required=True)
parser.add_argument("--existing-dir", type=Path, required=True)
parser.add_argument("--release-json", type=Path, required=True)
parser.add_argument("--version-file", type=Path, default=Path("VERSION"))
parser.add_argument("--tag", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument("--resolved-tag-commit", required=True)
args = parser.parse_args()
errors = validate_release_assets(
args.expected_dir,
args.existing_dir,
args.release_json,
args.version_file,
tag=args.tag,
source_commit=args.source_commit,
resolved_tag_commit=args.resolved_tag_commit,
)
if errors:
for error in errors:
print(f"release asset verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
print(f"Verified existing release {args.tag}: all assets and source identities are unchanged.")


if __name__ == "__main__":
main()
8 changes: 7 additions & 1 deletion tests/test_package_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ def test_package_workflow_uses_numeric_reproducibility_epoch() -> None:

def test_package_workflow_does_not_replace_published_release_assets() -> None:
workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8")
verifier = (Path(__file__).resolve().parents[1] / "scripts/verify_release_assets.py").read_text(encoding="utf-8")

assert "Create GitHub Release" in workflow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: this test still asserts "Create GitHub Release" in workflow, but the diff renamed the actual release step to "Verify and create immutable GitHub Release". It only still passes because of the unrelated job-level name: Create GitHub Release field — if the release-creation step is later removed, this assertion won't catch it.

assert "refusing to replace immutable release assets" in workflow
assert "verify_release_assets.py" in workflow
assert "refusing to replace immutable release assets" in verifier
assert "gh release download" in workflow
assert "gh attestation verify" in workflow
assert '--source-digest "$GITHUB_SHA"' in workflow
assert '--source-ref "$GITHUB_REF"' in workflow
assert "--clobber" not in workflow
Loading
Loading