Skip to content
Merged
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
112 changes: 108 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,110 @@ 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"
release_response="$release_tmp/release-response.txt"

read_release_metadata() {
local retry_404="${1:-false}"
local attempt=1
local status_line
while :; do
if gh api --include "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \
>"$release_response" 2>"$release_error"; then
awk 'BEGIN { body = 0 } { sub(/\r$/, ""); if (body) print; else if ($0 == "") body = 1 }' \
"$release_response" >"$release_json"
return 0
fi
status_line="$(sed -n '1s/\r$//p' "$release_response")"
if [[ "$status_line" =~ ^HTTP/[0-9.]+[[:space:]]404[[:space:]] ]]; then
if [[ "$retry_404" != true || "$attempt" -ge 5 ]]; then
return 1
fi
sleep $((attempt * 2))
attempt=$((attempt + 1))
continue
fi
cat "$release_error" >&2
return 2
done
}

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 true; then
verify_existing_release
if [[ "$create_status" -ne 0 ]]; then
echo "gh release create failed with status $create_status; refusing to hide the publication error." >&2
exit "$create_status"
fi
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
16 changes: 6 additions & 10 deletions scripts/generate_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
from __future__ import annotations

import argparse
import hashlib
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

try:
from .release_metadata_helpers import sha256_file
except ImportError: # pragma: no cover - direct script execution
from release_metadata_helpers import sha256_file

import tomllib # type: ignore[import-untyped]

PACKAGE_NAME = "base-cli"
Expand Down Expand Up @@ -42,14 +46,6 @@ def _created_at() -> str:
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat().replace("+00:00", "Z")


def _sha256(path: Path) -> str:
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 _spdx_id(value: str) -> str:
return "SPDXRef-" + "".join(character if character.isalnum() else "-" for character in value)

Expand Down Expand Up @@ -98,7 +94,7 @@ def generate(dist: Path, root: Path) -> None:
source_id = _spdx_id(PACKAGE_NAME)
dependency_packages, relationships = _dependency_packages(project)
(dist / CHECKSUMS_NAME).write_text(
"\n".join(f"{_sha256(path)} {path.name}" for path in artifacts) + "\n", encoding="utf-8"
"\n".join(f"{sha256_file(path)} {path.name}" for path in artifacts) + "\n", encoding="utf-8"
)
sbom: dict[str, Any] = {
"spdxVersion": "SPDX-2.3",
Expand Down
16 changes: 16 additions & 0 deletions scripts/release_metadata_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Shared helpers for deterministic release metadata validation."""

from __future__ import annotations

import hashlib
from pathlib import Path


def sha256_file(path: Path) -> str:
"""Return the SHA-256 digest of *path* in lowercase hexadecimal form."""

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()
16 changes: 6 additions & 10 deletions scripts/validate_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,23 @@
from __future__ import annotations

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

try:
from .release_metadata_helpers import sha256_file
except ImportError: # pragma: no cover - direct script execution
from release_metadata_helpers import sha256_file

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


def _sha256(path: Path) -> str:
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 _fail(message: str) -> None:
raise SystemExit(f"release metadata validation failed: {message}")

Expand All @@ -47,7 +43,7 @@ def main() -> None:
if set(rows) != {path.name for path in artifacts} or len(artifacts) != 2:
_fail("SHA256SUMS must cover exactly one wheel and one sdist")
for path in artifacts:
if _sha256(path) != rows[path.name]:
if sha256_file(path) != rows[path.name]:
_fail(f"checksum mismatch for {path.name}")
try:
sbom: dict[str, Any] = json.loads(sbom_path.read_text(encoding="utf-8"))
Expand Down
Loading
Loading