From 9394b2f5963299fd6620ed4d1bf3b1413bf73c72 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 24 Aug 2026 18:50:05 -0400 Subject: [PATCH] feat: add keyless provenance release template --- .github/workflows/ci.yml | 12 +- .../{release.yml => prepare-release.yml} | 41 ++- .github/workflows/publish-release.yml | 254 ++++++++++++++++++ .gitignore | 2 +- Justfile | 4 + README.md | 30 ++- scripts/pack.py | 110 +++++++- 7 files changed, 408 insertions(+), 45 deletions(-) rename .github/workflows/{release.yml => prepare-release.yml} (50%) create mode 100644 .github/workflows/publish-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cf900b..03e17ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: run: | cargo install just --version 1.57.0 --locked cargo install wasm-tools --version 1.252.0 --locked + cargo install b3sum --version 1.8.3 --locked - name: Build checksum-verified zstd 1.5.7 run: | curl --proto '=https' --tlsv1.2 -fsSLO https://github.com/facebook/zstd/releases/download/v1.5.7/zstd-1.5.7.tar.gz @@ -24,9 +25,10 @@ jobs: tar -xzf zstd-1.5.7.tar.gz make -C zstd-1.5.7 -j2 zstd echo "$PWD/zstd-1.5.7/programs" >> "$GITHUB_PATH" - - name: Build twice with identical canonical bytes + - name: Build twice with identical package and release identity run: | - just dist - first="$(sha256sum dist/*.sigil-plugin.tar.zst)" - just dist - test "$first" = "$(sha256sum dist/*.sigil-plugin.tar.zst)" + source_commit=0000000000000000000000000000000000000000 + just release-dist "$source_commit" + first="$(sha256sum dist/*.sigil-plugin.tar.zst dist/SHA256SUMS dist/release-manifest.json)" + just release-dist "$source_commit" + test "$first" = "$(sha256sum dist/*.sigil-plugin.tar.zst dist/SHA256SUMS dist/release-manifest.json)" diff --git a/.github/workflows/release.yml b/.github/workflows/prepare-release.yml similarity index 50% rename from .github/workflows/release.yml rename to .github/workflows/prepare-release.yml index 196cf19..2e67dc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,26 +1,23 @@ -name: release +name: prepare-release on: - push: - tags: ['v*'] + workflow_dispatch: permissions: contents: read jobs: - release: + assets: runs-on: ubuntu-24.04 - environment: release - permissions: - contents: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: - fetch-depth: 0 + persist-credentials: false - name: Install pinned build tools run: | cargo install just --version 1.57.0 --locked cargo install wasm-tools --version 1.252.0 --locked + cargo install b3sum --version 1.8.3 --locked - name: Build checksum-verified zstd 1.5.7 run: | curl --proto '=https' --tlsv1.2 -fsSLO https://github.com/facebook/zstd/releases/download/v1.5.7/zstd-1.5.7.tar.gz @@ -28,18 +25,16 @@ jobs: tar -xzf zstd-1.5.7.tar.gz make -C zstd-1.5.7 -j2 zstd echo "$PWD/zstd-1.5.7/programs" >> "$GITHUB_PATH" - - name: Verify tag and build canonical assets + - name: Build exact package and canonical release identity run: | - version="$(sed -n 's/^version = "\([^"]*\)"$/\1/p' plugin.toml)" - test "${GITHUB_REF_NAME#v}" = "$version" - just dist - - name: Publish immutable release - env: - GH_TOKEN: ${{ github.token }} - run: | - version="${GITHUB_REF_NAME#v}" - name="$(sed -n 's/^name = "\([^"]*\)"$/\1/p' plugin.toml)" - gh release create "$GITHUB_REF_NAME" \ - "dist/$name-$version.sigil-plugin.tar.zst" \ - dist/SHA256SUMS \ - --verify-tag --title "$name $version" --generate-notes + set -euo pipefail + test "$GITHUB_REF" = refs/heads/main + test "$(sed -n 's/^source = "\([^"]*\)"$/\1/p' plugin.toml)" = "github:${GITHUB_REPOSITORY}" + just release-dist "$GITHUB_SHA" + test "$(find dist -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 3 + test "$(find dist -mindepth 1 -maxdepth 1 | wc -l)" -eq 3 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-candidate-assets + path: dist/ + if-no-files-found: error diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..c1e3f6b --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,254 @@ +name: publish-release + +on: + workflow_dispatch: + inputs: + version: + description: Exact approved SemVer without v prefix + required: true + type: string + source_commit: + description: Exact approved 40-hex main commit + required: true + type: string + candidate_run_id: + description: Exact first-attempt prepare-release run + required: true + type: string + package_sha256: + description: Exact approved package SHA-256 + required: true + type: string + checksums_sha256: + description: Exact approved SHA256SUMS SHA-256 + required: true + type: string + release_manifest_sha256: + description: Exact approved release-manifest.json SHA-256 + required: true + type: string + +permissions: {} + +jobs: + publish: + runs-on: ubuntu-24.04 + environment: release + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: write + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + persist-credentials: false + - name: Verify dispatch and candidate build identity + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + [[ "$CANDIDATE_RUN_ID" =~ ^[1-9][0-9]*$ ]] + [[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_SHA" = "$SOURCE_COMMIT" + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + run_json="$(mktemp)" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${CANDIDATE_RUN_ID}" > "$run_json" + jq -e --arg repository "$GITHUB_REPOSITORY" --arg commit "$SOURCE_COMMIT" ' + .repository.full_name == $repository and + .path == ".github/workflows/prepare-release.yml" and + .event == "workflow_dispatch" and + .head_branch == "main" and + .head_sha == $commit and + .status == "completed" and + .conclusion == "success" and + .run_attempt == 1 + ' "$run_json" >/dev/null + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-candidate-assets + path: dist + github-token: ${{ github.token }} + run-id: ${{ inputs.candidate_run_id }} + - name: Verify exact approved unpublished bytes + id: release + env: + VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + CHECKSUMS_SHA256: ${{ inputs.checksums_sha256 }} + RELEASE_MANIFEST_SHA256: ${{ inputs.release_manifest_sha256 }} + run: | + set -euo pipefail + [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + [[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ "$PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ ]] + [[ "$CHECKSUMS_SHA256" =~ ^[0-9a-f]{64}$ ]] + [[ "$RELEASE_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] + name="$(sed -n 's/^name = "\([^"]*\)"$/\1/p' plugin.toml)" + source="$(sed -n 's/^source = "\([^"]*\)"$/\1/p' plugin.toml)" + test "$source" = "github:${GITHUB_REPOSITORY}" + test "$(sed -n 's/^version = "\([^"]*\)"$/\1/p' plugin.toml)" = "$VERSION" + package="${name}-${VERSION}.sigil-plugin.tar.zst" + test -f "dist/$package" && test ! -L "dist/$package" + test -f dist/SHA256SUMS && test ! -L dist/SHA256SUMS + test -f dist/release-manifest.json && test ! -L dist/release-manifest.json + test "$(find dist -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 3 + test "$(find dist -mindepth 1 -maxdepth 1 | wc -l)" -eq 3 + printf '%s %s\n' "$PACKAGE_SHA256" "dist/$package" | sha256sum --check --strict + printf '%s %s\n' "$CHECKSUMS_SHA256" dist/SHA256SUMS | sha256sum --check --strict + printf '%s %s\n' "$RELEASE_MANIFEST_SHA256" dist/release-manifest.json | sha256sum --check --strict + test "$(cat dist/SHA256SUMS)" = "$PACKAGE_SHA256 $package" + python3 -I - "$name" "$VERSION" "$SOURCE_COMMIT" "$source" "$package" "$PACKAGE_SHA256" <<'PY' + import json + from pathlib import Path + import re + import sys + + name, version, commit, source, package, package_sha256 = sys.argv[1:] + path = Path("dist/release-manifest.json") + raw = path.read_bytes() + value = json.loads(raw) + assert set(value) == { + "schema_version", "source", "source_commit", "name", "version", + "asset_name", "package_sha256", "package_blake3", + "manifest_blake3", "component_blake3", + } + assert value["schema_version"] == 1 + assert value["source"] == source + assert value["source_commit"] == commit + assert value["name"] == name + assert value["version"] == version + assert value["asset_name"] == package + assert value["package_sha256"] == f"sha256:{package_sha256}" + for field in ("package_blake3", "manifest_blake3", "component_blake3"): + assert re.fullmatch(r"blake3:[0-9a-f]{64}", value[field]) + canonical = json.dumps( + value, allow_nan=False, ensure_ascii=True, sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + assert raw == canonical + PY + echo "package_name=$package" >> "$GITHUB_OUTPUT" + echo "subject_digest=sha256:$PACKAGE_SHA256" >> "$GITHUB_OUTPUT" + - name: Stage and read back the exact draft + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + CHECKSUMS_SHA256: ${{ inputs.checksums_sha256 }} + RELEASE_MANIFEST_SHA256: ${{ inputs.release_manifest_sha256 }} + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + run: | + set -euo pipefail + tag="v${VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "existing tag burns ${tag}; refusing publication" >&2 + exit 1 + fi + if gh release view "$tag" >/dev/null 2>&1; then + echo "existing release burns ${tag}; refusing publication" >&2 + exit 1 + fi + status="$(curl --proto '=https' --tlsv1.2 --silent --show-error \ + --output "${RUNNER_TEMP}/preexisting-attestations.json" --write-out '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/attestations/sha256:${PACKAGE_SHA256}?predicate_type=provenance&per_page=30")" + test "$status" = 404 + gh release create "$tag" \ + --target "$SOURCE_COMMIT" \ + --title "${PACKAGE_NAME%.sigil-plugin.tar.zst}" \ + --notes "Immutable keyless-provenance Sigil plugin ${VERSION}." \ + --draft \ + "dist/$PACKAGE_NAME" dist/SHA256SUMS dist/release-manifest.json + release_json="$(mktemp)" + gh release view "$tag" --json isDraft,isPrerelease,targetCommitish,assets > "$release_json" + jq -e --arg commit "$SOURCE_COMMIT" --arg package "$PACKAGE_NAME" ' + .isDraft == true and .isPrerelease == false and .targetCommitish == $commit and + ([.assets[].name] | sort) == (["SHA256SUMS", "release-manifest.json", $package] | sort) + ' "$release_json" >/dev/null + readback="$(mktemp -d)" + gh release download "$tag" --dir "$readback" + test "$(find "$readback" -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 3 + test "$(find "$readback" -mindepth 1 -maxdepth 1 | wc -l)" -eq 3 + test "$(sha256sum "$readback/$PACKAGE_NAME" | cut -d' ' -f1)" = "$PACKAGE_SHA256" + test "$(sha256sum "$readback/SHA256SUMS" | cut -d' ' -f1)" = "$CHECKSUMS_SHA256" + test "$(sha256sum "$readback/release-manifest.json" | cut -d' ' -f1)" = "$RELEASE_MANIFEST_SHA256" + test "$(cat "$readback/SHA256SUMS")" = "$PACKAGE_SHA256 $PACKAGE_NAME" + - name: Create GitHub OIDC build provenance for the exact package + id: attest + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-name: ${{ steps.release.outputs.package_name }} + subject-digest: ${{ steps.release.outputs.subject_digest }} + show-summary: false + - name: Publish once and verify immutable public state + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + CHECKSUMS_SHA256: ${{ inputs.checksums_sha256 }} + RELEASE_MANIFEST_SHA256: ${{ inputs.release_manifest_sha256 }} + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }} + BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} + run: | + set -euo pipefail + test -n "$ATTESTATION_ID" + test -f "$BUNDLE_PATH" && test ! -L "$BUNDLE_PATH" + attestation_found=false + for _ in 1 2 3 4 5 6; do + status="$(curl --proto '=https' --tlsv1.2 --silent --show-error \ + --output "${RUNNER_TEMP}/attestations.json" --write-out '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/attestations/sha256:${PACKAGE_SHA256}?predicate_type=provenance&per_page=30")" + if test "$status" = 200 && jq -e --argjson repository_id "$GITHUB_REPOSITORY_ID" ' + (.attestations | length) == 1 and + .attestations[0].repository_id == $repository_id and + .attestations[0].initiator == "user" + ' "${RUNNER_TEMP}/attestations.json" >/dev/null; then + attestation_found=true + break + fi + sleep 5 + done + test "$attestation_found" = true + tag="v${VERSION}" + test "$(gh release view "$tag" --json isDraft,targetCommitish --jq '.isDraft')" = true + test "$(gh release view "$tag" --json isDraft,targetCommitish --jq '.targetCommitish')" = "$SOURCE_COMMIT" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "tag appeared after preflight; ${tag} is burned" >&2 + exit 1 + fi + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${tag}" -f sha="$SOURCE_COMMIT" >/dev/null + git fetch --force origin "refs/tags/$tag:refs/tags/$tag" + test "$(git rev-list -n 1 "$tag")" = "$SOURCE_COMMIT" + gh release edit "$tag" --draft=false --latest=false + immutable=false + for _ in 1 2 3 4 5 6; do + if test "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq '.immutable')" = true; then + immutable=true + break + fi + sleep 5 + done + test "$immutable" = true + test "$(gh release view "$tag" --json isDraft --jq '.isDraft')" = false + readback="$(mktemp -d)" + gh release download "$tag" --dir "$readback" + test "$(find "$readback" -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 3 + test "$(find "$readback" -mindepth 1 -maxdepth 1 | wc -l)" -eq 3 + test "$(sha256sum "$readback/$PACKAGE_NAME" | cut -d' ' -f1)" = "$PACKAGE_SHA256" + test "$(sha256sum "$readback/SHA256SUMS" | cut -d' ' -f1)" = "$CHECKSUMS_SHA256" + test "$(sha256sum "$readback/release-manifest.json" | cut -d' ' -f1)" = "$RELEASE_MANIFEST_SHA256" diff --git a/.gitignore b/.gitignore index b7761c5..729b472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /build/ /dist/ /plugin.wasm - +__pycache__/ diff --git a/Justfile b/Justfile index b9e9b34..77806af 100644 --- a/Justfile +++ b/Justfile @@ -20,3 +20,7 @@ sigil-check: check dist: check mkdir -p dist {{python}} scripts/pack.py plugin.toml dist + +release-dist source_commit: check + mkdir -p dist + {{python}} scripts/pack.py plugin.toml dist --source-commit "{{source_commit}}" diff --git a/README.md b/README.md index 21e12e4..5f0d597 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,14 @@ Before publishing a derived plugin: contract and declares exactly the matching capabilities. 3. Run `just check` with `wasm-tools 1.252.0`, then run `just sigil-check` with a Sigil release that provides the `plugin` command. -4. Review the exact tag, source commit, package, and `SHA256SUMS` bytes. -5. Configure the repository's protected `release` environment before pushing - a tag. Published versions are immutable; recovery always uses a new SemVer. +4. Configure the protected `release` environment to allow only `main` and to + require an explicit human reviewer. +5. Dispatch `prepare-release` from the reviewed `main` commit, reproduce its + package, `SHA256SUMS`, and canonical `release-manifest.json` locally, then + review the exact candidate run and digests. +6. Dispatch `publish-release` once with that approved tuple. Existing or + partial tags, releases, or attestations burn the SemVer; recovery always + prepares and approves a new version. The component is built from checked-in WIT and core WAT, then validated and packed into Sigil's canonical P3 archive: @@ -26,11 +31,16 @@ just dist just sigil-check ``` -The bootstrap workflows pin `wasm-tools`, zstd 1.5.7 source, and every Action -commit. The small compatibility packer is byte-identical to Sigil P3 and -avoids depending on an unreleased Sigil command; installation still performs -Sigil's complete manifest, component, archive, and digest validation. +The workflows pin `wasm-tools`, zstd 1.5.7 source, and every Action commit. The +small compatibility packer is byte-identical to Sigil and avoids depending on +an unreleased Sigil command. The publisher uses only the ephemeral GitHub token +and GitHub OIDC: there is no long-lived signing secret. Its exact +`workflow_dispatch`/`main`/`release` identity is part of the Sigstore proof. -The release workflow produces only `NAME-VERSION.sigil-plugin.tar.zst` and -`SHA256SUMS`. A capability request is not a capability grant, and installation -is not a project evaluation lock. +The immutable release contains exactly `NAME-VERSION.sigil-plugin.tar.zst`, +`SHA256SUMS`, and `release-manifest.json`; the attestation is read through +GitHub's artifact-attestations API. Sigil's closed official provenance profile +applies only to reviewed `sigil-plugins/*` repositories. A derived third-party +repository remains third-party evidence even if it uses the same workflow. A +capability request is not a capability grant, and installation is not a +project evaluation lock. diff --git a/scripts/pack.py b/scripts/pack.py index 2db13be..d21e5a0 100644 --- a/scripts/pack.py +++ b/scripts/pack.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Emit Sigil's canonical two-member P3 plugin archive.""" +"""Emit Sigil's canonical package and optional P6 release identity.""" from __future__ import annotations import argparse import hashlib +import json import os from pathlib import Path, PurePosixPath import re @@ -13,6 +14,12 @@ import tomllib BLOCK = 512 +COMMIT = re.compile(r"[0-9a-f]{40}") +SEMVER = re.compile( + r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) def octal_field(width: int, value: int) -> bytes: @@ -73,10 +80,95 @@ def write_member(stream, archive_path: str, source: Path) -> None: stream.write(b"\0" * ((BLOCK - size % BLOCK) % BLOCK)) +def file_digest(path: Path, algorithm: str) -> str: + with path.open("rb") as handle: + return hashlib.file_digest(handle, algorithm).hexdigest() + + +def blake3_digest(path: Path) -> str: + result = subprocess.run( + [os.environ.get("B3SUM", "b3sum"), "--no-names", str(path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + digest = result.stdout.decode("ascii").strip() + if re.fullmatch(r"[0-9a-f]{64}", digest) is None: + raise RuntimeError("b3sum returned a non-canonical digest") + return digest + + +def write_atomic(path: Path, data: bytes) -> None: + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def canonical_semver(value: object) -> bool: + if not isinstance(value, str) or SEMVER.fullmatch(value) is None: + return False + release_without_build = value.split("+", maxsplit=1)[0] + if "-" not in release_without_build: + return True + prerelease = release_without_build.split("-", maxsplit=1)[1] + return not any( + identifier.isdigit() and len(identifier) > 1 and identifier.startswith("0") + for identifier in prerelease.split(".") + ) + + +def release_manifest( + parsed: dict[str, object], + manifest: Path, + component: Path, + package: Path, + source_commit: str, +) -> bytes: + if COMMIT.fullmatch(source_commit) is None: + raise SystemExit("source commit must be exactly 40 lowercase hexadecimal characters") + name = parsed.get("name") + version = parsed.get("version") + repository = parsed.get("repository") + source = repository.get("source") if isinstance(repository, dict) else None + if not isinstance(source, str) or re.fullmatch( + r"github:[a-z0-9_](?:[a-z0-9_-]{0,98}[a-z0-9_])?/[a-z0-9_.](?:[a-z0-9_.-]{0,98}[a-z0-9_.])?", + source, + ) is None: + raise SystemExit("repository source is not canonical GitHub syntax") + value = { + "schema_version": 1, + "source": source, + "source_commit": source_commit, + "name": name, + "version": version, + "asset_name": package.name, + "package_sha256": f"sha256:{file_digest(package, 'sha256')}", + "package_blake3": f"blake3:{blake3_digest(package)}", + "manifest_blake3": f"blake3:{blake3_digest(manifest)}", + "component_blake3": f"blake3:{blake3_digest(component)}", + } + return json.dumps( + value, + allow_nan=False, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("manifest", type=Path) parser.add_argument("output_dir", type=Path) + parser.add_argument("--source-commit") args = parser.parse_args() manifest = args.manifest @@ -89,8 +181,8 @@ def main() -> None: component_raw = parsed.get("component", {}).get("file") if not isinstance(name, str) or re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", name) is None: raise SystemExit("manifest plugin name is not canonical") - if not isinstance(version, str) or re.fullmatch(r"[0-9A-Za-z.+-]+", version) is None: - raise SystemExit("manifest version is not filename-safe") + if not canonical_semver(version): + raise SystemExit("manifest version is not canonical SemVer") if not isinstance(component_raw, str): raise SystemExit("manifest component file is missing") component_path = PurePosixPath(component_raw) @@ -136,12 +228,18 @@ def main() -> None: temporary_path.unlink(missing_ok=True) raise - with output.open("rb") as handle: - digest = hashlib.file_digest(handle, "sha256").hexdigest() + digest = file_digest(output, "sha256") checksum = args.output_dir / "SHA256SUMS" - checksum.write_text(f"{digest} {output.name}\n", encoding="ascii") + write_atomic(checksum, f"{digest} {output.name}\n".encode("ascii")) print(output) print(checksum) + if args.source_commit is not None: + release = args.output_dir / "release-manifest.json" + write_atomic( + release, + release_manifest(parsed, manifest, component, output, args.source_commit), + ) + print(release) if __name__ == "__main__":