From 0297ca8b3100b4e42d314ef6adb83056f3b34490 Mon Sep 17 00:00:00 2001 From: Olize <53581423+Olize@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:49:30 +0200 Subject: [PATCH 1/2] security: seal release packages after draft SHA256 verification --- .github/workflows/ci.yml | 8 +++ .github/workflows/release-packages.yml | 83 ++++++++++++++++++++------ docs/RELEASE_INTEGRITY.md | 13 ++++ scripts/check-release-policy.py | 25 ++++++++ 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 docs/RELEASE_INTEGRITY.md create mode 100644 scripts/check-release-policy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51c0adc..ccd30b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,3 +87,11 @@ jobs: exit 1 fi sha256sum "$binary" + + release-policy: + name: Check immutable-release workflow policy + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Require tag-first, draft-first, pinned, no-clobber release pipeline + run: python3 scripts/check-release-policy.py diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index bda4d36..a6d7ab2 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -1,25 +1,21 @@ name: Release packages +# Trigger on the NEW tag; create a draft, attach all assets, then publish. +# Never rebuild immutable published releases or replace any downloaded asset. on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: "Existing stable release tag, e.g. v0.2.1" - required: true - type: string + push: + tags: ['v[0-9]*'] permissions: contents: read concurrency: - group: release-packages-${{ github.event.release.tag_name || inputs.tag }} + group: release-packages-${{ github.ref_name }} cancel-in-progress: false jobs: verify: - name: Validate published tag + name: Validate new stable tag runs-on: ubuntu-24.04 outputs: tag: ${{ steps.guard.outputs.tag }} @@ -30,10 +26,10 @@ jobs: fetch-depth: 0 persist-credentials: false - id: guard - name: Check tag, version, repository history and published release + name: Check tag, main ancestry and nonpublished draft shell: bash env: - TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} + TAG: ${{ github.ref_name }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -43,9 +39,17 @@ jobs: git merge-base --is-ancestor "$sha" origin/main || { echo "Tag is not on public main"; exit 1; } version="$(git show "$sha:Cargo.toml" | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)" [[ "$TAG" == "v$version" ]] || { echo "Tag and Cargo version mismatch"; exit 1; } - [[ "$TAG" != v0.2.1 ]] || { echo 'Historical v0.2.1 assets must remain immutable; ARM64 archives begin with a future release'; exit 1; } - [[ "$(gh release view "$TAG" --json isDraft,isPrerelease --jq '.isDraft or .isPrerelease')" == false ]] || { echo "Release must be published and stable"; exit 1; } - [[ "$(gh release view "$TAG" --json tagName --jq .tagName)" == "$TAG" ]] || { echo "Release tag identity mismatch"; exit 1; } + # Deny all historical 0.2.0–0.2.2 rebuilds, even via a moved tag. + python3 - "$version" <<'VERSION_GUARD' + import sys + version = tuple(map(int, sys.argv[1].split("."))) + if version < (0, 2, 3): + raise SystemExit("Historical tag: refuse rebuild") + VERSION_GUARD + if gh release view "$TAG" --json isDraft --jq .isDraft > /tmp/mch-draft-status 2>/dev/null; then + [[ "$(cat /tmp/mch-draft-status)" == true ]] || { echo "Already published; refusing replacement"; exit 1; } + [[ "$(gh release view "$TAG" --json assets --jq '.assets | length')" -eq 0 ]] || { echo "Draft contains assets: manual review required"; exit 1; } + fi printf 'tag=%s\nsha=%s\n' "$TAG" "$sha" >> "$GITHUB_OUTPUT" binary: @@ -87,6 +91,8 @@ jobs: ref: ${{ needs.verify.outputs.sha }} persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec + with: + toolchain: "1.99.1" - name: Assert native architecture, test and build shell: bash run: | @@ -245,11 +251,50 @@ jobs: out.write(f"{digest} {filename}\n") print((folder / "SHA256SUMS.txt").read_text()) PY - - name: Upload to the existing published GitHub release + - name: Open empty draft; fail on existing published release or assets + shell: bash + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft > /tmp/mch-draft 2>/dev/null; then + [[ "$(cat /tmp/mch-draft)" == true ]] || { echo "Refusing published release"; exit 1; } + [[ "$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" -eq 0 ]] || { echo "Draft already contains assets"; exit 1; } + else + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --draft --title "MCH $TAG" --notes "Stable MCH $TAG release. See CHANGELOG.md and verify download checksums." + fi + - name: Upload verified assets into empty draft + shell: bash + run: | + set -euo pipefail + gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" + - name: Independently confirm all eight uploaded SHA-256 digests + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + repo = os.environ["GITHUB_REPOSITORY"] + tag = os.environ["TAG"] + release = json.loads(subprocess.check_output(["gh", "api", f"repos/{repo}/releases/tags/{tag}"], text=True)) + assert release["draft"] is True, "Release was published before asset verification" + local = {p.name: p for p in Path("dist").iterdir() if p.is_file()} + uploaded = {a["name"]: a for a in release["assets"]} + assert local.keys() == uploaded.keys() and len(local) == 8, "Release assets differ" + for name, path in sorted(local.items()): + digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + assert uploaded[name].get("digest") == digest, f"Hash mismatch: {name}" + print("All eight uploaded assets match verified local SHA256") + PY + - name: Publish verified draft and REQUIRE GitHub immutability shell: bash run: | set -euo pipefail - gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + sealed="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" + [[ "$sealed" == true ]] || { echo "CRITICAL: published release is NOT immutable"; exit 1; } count="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" - [[ "$count" -eq 8 ]] || { echo "Expected eight release assets, found $count"; exit 1; } - gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]' + [[ "$count" -eq 8 ]] || { echo "Expected eight immutable release assets"; exit 1; } + echo "Published $TAG: eight verified immutable assets" diff --git a/docs/RELEASE_INTEGRITY.md b/docs/RELEASE_INTEGRITY.md new file mode 100644 index 0000000..eb1b74f --- /dev/null +++ b/docs/RELEASE_INTEGRITY.md @@ -0,0 +1,13 @@ +# MCH Release Integrity Policy (from v0.2.3) + +- Repository setting **Enable release immutability** must be enabled **before** any release is published (verified 2026-10-22 through GitHub's `/immutable-releases` API). +- Release pipeline starts only on a new stable `vX.Y.Z` tag pushed from an ancestor of public `main`. Manual reruns targeting already published releases are deliberately unsupported. +- `verify` rejects pre-existing published releases, tags older than v0.2.3, malformed versions, tags not on `main`, and tags disagreeing with the package version. +- Five platform binaries and the two source archives are built with the exact Rust toolchain version **1.99.1** and pinned GitHub Actions versions. Android output is cross-compiled with Bionic and its dynamic linker explicitly inspected. +- A **draft** release is created only after every build job and archive validation passed. Seven archives and a SHA256SUMS are uploaded **without overwriting** prior assets. +- Exactly eight remote asset digests are rechecked before the draft is made public. GitHub's `immutable` property must be true after publication. +- Incomplete or failed draft builds are deliberately not automatically published. Manual inspection and a **new tag / patch release** are safer than overwriting immutable downloads. +- Old v0.2.2 and earlier artifacts remain mutable: repository immutability changes apply to future releases only. Never use this new workflow to re-upload their assets. +- Releasing code still requires explicit review, independent CI and actual target-platform testing; workflow safeguards do not substitute for review. + +Do not create a published release manually before the tag-triggered pipeline completes: GitHub will immediately seal an immutable empty release, preventing attachments. diff --git a/scripts/check-release-policy.py b/scripts/check-release-policy.py new file mode 100644 index 0000000..d1353ac --- /dev/null +++ b/scripts/check-release-policy.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Fail-closed source policy for the public release workflow.""" +from pathlib import Path +import re + +root = Path(__file__).resolve().parents[1] +workflow = (root / ".github/workflows/release-packages.yml").read_text() +def check(cond, message): + if not cond: + raise SystemExit("RELEASE POLICY BLOCK: " + message) + +check(" push:\n tags:" in workflow, "tag push must start release build") +check(" workflow_dispatch:" not in workflow, "manual rebuild trigger forbidden") +check("types: [published]" not in workflow, "published-release trigger too late") +check(bool(re.search(r'^\s+toolchain:\s*"?\d+\.\d+\.\d+"?\s*$', workflow, re.M)), "pin exact Rust version") +check("gh release create \"$TAG\"" in workflow and "--draft" in workflow, "draft before publish") +check("gh release upload \"$TAG\" dist/*" in workflow, "expected upload") +check(not re.search(r'^\s*gh release upload[^\n]*--clobber', workflow, re.M), "no clobber") +check('gh release edit "$TAG"' in workflow and "--draft=false" in workflow, "publish verified draft") +check('uploaded[name].get("digest")' in workflow, "verify remote SHA256 digests") +check('".immutable"' in workflow or ".immutable)" in workflow or "--jq .immutable" in workflow, "check immutable") +check("Already published; refusing replacement" in workflow, "refuse published tag") +check("Historical tag: refuse rebuild" in workflow, "no historical rebuilds") +check("Expected eight immutable release assets" in workflow, "final asset count") +print("PUBLIC_RELEASE_POLICY_PASS") From 8e5ec899e2b7494cb10e31e26087d2e8a8168ae6 Mon Sep 17 00:00:00 2001 From: Olize <53581423+Olize@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:58:54 +0200 Subject: [PATCH 2/2] fix: verify draft assets via GraphQL and remote tag identity twice --- .github/workflows/release-packages.yml | 27 ++++++++++++++++++++++++-- scripts/check-release-policy.py | 4 ++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index a6d7ab2..7a643a4 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -197,6 +197,7 @@ jobs: contents: write env: TAG: ${{ needs.verify.outputs.tag }} + RELEASE_SHA: ${{ needs.verify.outputs.sha }} GH_TOKEN: ${{ github.token }} steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 @@ -255,6 +256,16 @@ jobs: shell: bash run: | set -euo pipefail + # Tag immutability begins only on publication. Guard against + # a force-moved tag while the platform build jobs were running. + object_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.type)" + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.sha)" + if [[ "$object_type" == tag ]]; then + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$remote_sha" --jq .object.sha)" + else + [[ "$object_type" == commit ]] || { echo "Unsupported tag target"; exit 1; } + fi + [[ "$remote_sha" == "$RELEASE_SHA" ]] || { echo "Remote tag moved after validation"; exit 1; } if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft > /tmp/mch-draft 2>/dev/null; then [[ "$(cat /tmp/mch-draft)" == true ]] || { echo "Refusing published release"; exit 1; } [[ "$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" -eq 0 ]] || { echo "Draft already contains assets"; exit 1; } @@ -278,8 +289,11 @@ jobs: import subprocess repo = os.environ["GITHUB_REPOSITORY"] tag = os.environ["TAG"] - release = json.loads(subprocess.check_output(["gh", "api", f"repos/{repo}/releases/tags/{tag}"], text=True)) - assert release["draft"] is True, "Release was published before asset verification" + # GitHub's by-tag REST endpoint omits unpublished drafts. + # The gh release view command uses draft-aware GraphQL instead. + release = json.loads(subprocess.check_output( + ["gh", "release", "view", tag, "--repo", repo, "--json", "isDraft,assets"], text=True)) + assert release["isDraft"] is True, "Release was published before asset verification" local = {p.name: p for p in Path("dist").iterdir() if p.is_file()} uploaded = {a["name"]: a for a in release["assets"]} assert local.keys() == uploaded.keys() and len(local) == 8, "Release assets differ" @@ -292,6 +306,15 @@ jobs: shell: bash run: | set -euo pipefail + # Independently verify tag identity AGAIN immediately before sealing. + object_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.type)" + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.sha)" + if [[ "$object_type" == tag ]]; then + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$remote_sha" --jq .object.sha)" + else + [[ "$object_type" == commit ]] || { echo "Unsupported tag target"; exit 1; } + fi + [[ "$remote_sha" == "$RELEASE_SHA" ]] || { echo "Remote tag changed before publication"; exit 1; } gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest sealed="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" [[ "$sealed" == true ]] || { echo "CRITICAL: published release is NOT immutable"; exit 1; } diff --git a/scripts/check-release-policy.py b/scripts/check-release-policy.py index d1353ac..c44cb4f 100644 --- a/scripts/check-release-policy.py +++ b/scripts/check-release-policy.py @@ -18,6 +18,10 @@ def check(cond, message): check(not re.search(r'^\s*gh release upload[^\n]*--clobber', workflow, re.M), "no clobber") check('gh release edit "$TAG"' in workflow and "--draft=false" in workflow, "publish verified draft") check('uploaded[name].get("digest")' in workflow, "verify remote SHA256 digests") +check('"isDraft,assets"' in workflow and '["gh", "release", "view"' in workflow, + "draft-aware asset inspection; REST by-tag omits drafts") +check("Remote tag moved after validation" in workflow and "Remote tag changed before publication" in workflow, + "compare peeled remote tag with pinned verified commit before draft and publish") check('".immutable"' in workflow or ".immutable)" in workflow or "--jq .immutable" in workflow, "check immutable") check("Already published; refusing replacement" in workflow, "refuse published tag") check("Historical tag: refuse rebuild" in workflow, "no historical rebuilds")