From 9ae69e6b381a4d53efd78130eb9d33312d9c6a27 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Fri, 17 Jul 2026 21:30:05 -0400 Subject: [PATCH] ci: bind releases to immutable source identity --- .changes/immutable-release-identity.md | 7 + .../release-immutability.json | 3 + .github/rulesets/protect-release-tags.json | 22 ++ .github/workflows/release.yaml | 226 +++++++++++------ docs/constant-time.md | 16 +- docs/release.md | 71 ++++-- justfile | 2 + scripts/README.md | 5 + scripts/ci/check-ci-ownership-test.sh | 23 ++ scripts/ci/check-ci-ownership.sh | 62 +++++ scripts/ci/package-release-source.sh | 129 ++++++++++ scripts/ci/pre-push.sh | 2 +- scripts/ci/publish-immutable-release-test.sh | 158 ++++++++++++ scripts/ci/publish-immutable-release.sh | 152 +++++++++++ scripts/ci/release-identity-test.sh | 151 +++++++++++ scripts/ci/release-preflight.sh | 5 + .../ci/repository-controls-evidence-test.sh | 68 ++++- scripts/ci/repository-controls-evidence.sh | 115 ++++++++- scripts/ci/write-release-manifest.sh | 238 ++++++++++++++++++ 19 files changed, 1346 insertions(+), 109 deletions(-) create mode 100644 .changes/immutable-release-identity.md create mode 100644 .github/repository-settings/release-immutability.json create mode 100644 .github/rulesets/protect-release-tags.json create mode 100755 scripts/ci/package-release-source.sh create mode 100755 scripts/ci/publish-immutable-release-test.sh create mode 100755 scripts/ci/publish-immutable-release.sh create mode 100755 scripts/ci/release-identity-test.sh create mode 100755 scripts/ci/write-release-manifest.sh diff --git a/.changes/immutable-release-identity.md b/.changes/immutable-release-identity.md new file mode 100644 index 00000000..cc7218f9 --- /dev/null +++ b/.changes/immutable-release-identity.md @@ -0,0 +1,7 @@ +--- +"rscrypto" = "patch" +--- + +Immutable releases now bind a protected tag and exact commit to a deterministic +source archive, crate, evidence, checksums, and pinned toolchain in an attested +manifest. diff --git a/.github/repository-settings/release-immutability.json b/.github/repository-settings/release-immutability.json new file mode 100644 index 00000000..4e609c71 --- /dev/null +++ b/.github/repository-settings/release-immutability.json @@ -0,0 +1,3 @@ +{ + "enabled": true +} diff --git a/.github/rulesets/protect-release-tags.json b/.github/rulesets/protect-release-tags.json new file mode 100644 index 00000000..ea714bd0 --- /dev/null +++ b/.github/rulesets/protect-release-tags.json @@ -0,0 +1,22 @@ +{ + "name": "protect-release-tags", + "target": "tag", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/tags/v*" + ] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "deletion" + }, + { + "type": "update" + } + ] +} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1d507588..5247183a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,6 +26,8 @@ jobs: crate_name: ${{ steps.preflight.outputs.crate_name }} crate_sha256: ${{ steps.preflight.outputs.crate_sha256 }} crate_version: ${{ steps.preflight.outputs.crate_version }} + source_name: ${{ steps.preflight.outputs.source_name }} + source_sha256: ${{ steps.preflight.outputs.source_sha256 }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -46,11 +48,13 @@ jobs: id: preflight run: scripts/ci/release-preflight.sh --crate rscrypto --tag "$GITHUB_REF_NAME" - - name: Preserve validated crate + - name: Preserve validated release inputs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-crate-${{ github.sha }} - path: ${{ steps.preflight.outputs.crate_path }} + name: release-inputs-${{ github.sha }} + path: | + ${{ steps.preflight.outputs.crate_path }} + ${{ steps.preflight.outputs.source_path }} if-no-files-found: error retention-days: 1 compression-level: 0 @@ -83,26 +87,36 @@ jobs: enable-magic-cache: false enable-rust-cache: true - - name: Restore validated crate + - name: Restore validated release inputs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: release-crate-${{ github.sha }} + name: release-inputs-${{ github.sha }} path: target/package - - name: Verify validated crate + - name: Verify validated release inputs id: package env: CRATE_NAME: ${{ needs.preflight.outputs.crate_name }} CRATE_SHA256: ${{ needs.preflight.outputs.crate_sha256 }} CRATE_VERSION: ${{ needs.preflight.outputs.crate_version }} + SOURCE_NAME: ${{ needs.preflight.outputs.source_name }} + SOURCE_SHA256: ${{ needs.preflight.outputs.source_sha256 }} run: | set -euo pipefail crate_path="target/package/$CRATE_NAME" - actual_sha256="$(sha256sum "$crate_path" | awk '{print $1}')" - if [[ "$actual_sha256" != "$CRATE_SHA256" ]]; then + source_path="target/package/$SOURCE_NAME" + actual_crate_sha256="$(sha256sum "$crate_path" | awk '{print $1}')" + actual_source_sha256="$(sha256sum "$source_path" | awk '{print $1}')" + if [[ "$actual_crate_sha256" != "$CRATE_SHA256" ]]; then echo "validated crate sha256 changed during artifact transfer" >&2 echo "expected: $CRATE_SHA256" >&2 - echo "actual: $actual_sha256" >&2 + echo "actual: $actual_crate_sha256" >&2 + exit 1 + fi + if [[ "$actual_source_sha256" != "$SOURCE_SHA256" ]]; then + echo "validated source archive sha256 changed during artifact transfer" >&2 + echo "expected: $SOURCE_SHA256" >&2 + echo "actual: $actual_source_sha256" >&2 exit 1 fi { @@ -110,6 +124,9 @@ jobs: echo "crate_name=$CRATE_NAME" echo "crate_sha256=$CRATE_SHA256" echo "crate_version=$CRATE_VERSION" + echo "source_path=$source_path" + echo "source_name=$SOURCE_NAME" + echo "source_sha256=$SOURCE_SHA256" } >> "$GITHUB_OUTPUT" - name: Attest crate provenance @@ -117,6 +134,11 @@ jobs: with: subject-path: ${{ steps.package.outputs.crate_path }} + - name: Attest source archive provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: ${{ steps.package.outputs.source_path }} + - name: Capture repository controls id: repository_controls env: @@ -167,6 +189,34 @@ jobs: with: subject-path: ${{ steps.ct_evidence.outputs.bundle_path }} + - name: Write release identity manifest + id: release_manifest + env: + CRATE_VERSION: ${{ steps.package.outputs.crate_version }} + CRATE_PATH: ${{ steps.package.outputs.crate_path }} + SOURCE_PATH: ${{ steps.package.outputs.source_path }} + CT_EVIDENCE_PATH: ${{ steps.ct_evidence.outputs.bundle_path }} + REPOSITORY_CONTROLS_PATH: ${{ steps.repository_controls.outputs.evidence_path }} + EVIDENCE_COMMIT: ${{ needs.evidence-gate.outputs.weekly_commit }} + EVIDENCE_MODE: ${{ needs.evidence-gate.outputs.weekly_evidence_mode }} + run: | + scripts/ci/write-release-manifest.sh \ + --version "$CRATE_VERSION" \ + --tag "$GITHUB_REF_NAME" \ + --commit "$GITHUB_SHA" \ + --source "$SOURCE_PATH" \ + --crate "$CRATE_PATH" \ + --ct-evidence "$CT_EVIDENCE_PATH" \ + --repository-controls "$REPOSITORY_CONTROLS_PATH" \ + --evidence-commit "$EVIDENCE_COMMIT" \ + --evidence-mode "$EVIDENCE_MODE" \ + --output "release-artifacts/rscrypto-${CRATE_VERSION}-release-manifest.json" + + - name: Attest release identity manifest + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: ${{ steps.release_manifest.outputs.manifest_path }} + - name: Check existing crates.io package id: existing env: @@ -188,67 +238,32 @@ jobs: echo "published=false" >> "$GITHUB_OUTPUT" fi - - name: Authenticate with crates.io - if: steps.existing.outputs.published != 'true' - id: auth - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 - - - name: Publish to crates.io - if: steps.existing.outputs.published != 'true' - env: - CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - CRATE_PATH: ${{ steps.package.outputs.crate_path }} - CRATE_SHA256: ${{ steps.package.outputs.crate_sha256 }} - run: | - set -euo pipefail - before_sha="$(sha256sum "$CRATE_PATH" | awk '{print $1}')" - cargo publish -p rscrypto --locked - after_sha="$(sha256sum "$CRATE_PATH" | awk '{print $1}')" - if [[ "$before_sha" != "$after_sha" || "$after_sha" != "$CRATE_SHA256" ]]; then - echo "cargo publish changed the package artifact" >&2 - echo "before: $before_sha" >&2 - echo "after: $after_sha" >&2 - echo "expected: $CRATE_SHA256" >&2 - exit 1 - fi - - - name: Verify crates.io package - env: - CRATE_SHA256: ${{ steps.package.outputs.crate_sha256 }} - CRATE_VERSION: ${{ steps.package.outputs.crate_version }} - run: | - set -euo pipefail - url="https://crates.io/api/v1/crates/rscrypto/${CRATE_VERSION}/download" - for attempt in {1..18}; do - if curl -fsSL -A "rscrypto-release/${CRATE_VERSION} (https://github.com/loadingalias/rscrypto)" "$url" -o published.crate; then - published_sha="$(sha256sum published.crate | awk '{print $1}')" - if [[ "$published_sha" == "$CRATE_SHA256" ]]; then - exit 0 - fi - echo "crates.io sha256 mismatch for rscrypto ${CRATE_VERSION}" >&2 - echo "expected: $CRATE_SHA256" >&2 - echo "actual: $published_sha" >&2 - exit 1 - fi - echo "crates.io package not available yet; retry $attempt/18" - sleep 10 - done - echo "timed out waiting for rscrypto ${CRATE_VERSION} on crates.io" >&2 - exit 1 - - name: Write release checksums env: CRATE_NAME: ${{ steps.package.outputs.crate_name }} CRATE_SHA256: ${{ steps.package.outputs.crate_sha256 }} + SOURCE_NAME: ${{ steps.package.outputs.source_name }} + SOURCE_SHA256: ${{ steps.package.outputs.source_sha256 }} CT_BUNDLE_NAME: ${{ steps.ct_evidence.outputs.bundle_name }} CT_BUNDLE_SHA256: ${{ steps.ct_evidence.outputs.bundle_sha256 }} REPOSITORY_CONTROLS_NAME: ${{ steps.repository_controls.outputs.evidence_name }} REPOSITORY_CONTROLS_SHA256: ${{ steps.repository_controls.outputs.evidence_sha256 }} + RELEASE_MANIFEST_NAME: ${{ steps.release_manifest.outputs.manifest_name }} + RELEASE_MANIFEST_SHA256: ${{ steps.release_manifest.outputs.manifest_sha256 }} run: | set -euo pipefail - printf '%s %s\n' "$CRATE_SHA256" "$CRATE_NAME" > SHA256SUMS - printf '%s %s\n' "$CT_BUNDLE_SHA256" "$CT_BUNDLE_NAME" >> SHA256SUMS - printf '%s %s\n' "$REPOSITORY_CONTROLS_SHA256" "$REPOSITORY_CONTROLS_NAME" >> SHA256SUMS + { + printf '%s %s\n' "$CRATE_SHA256" "$CRATE_NAME" + printf '%s %s\n' "$SOURCE_SHA256" "$SOURCE_NAME" + printf '%s %s\n' "$CT_BUNDLE_SHA256" "$CT_BUNDLE_NAME" + printf '%s %s\n' "$REPOSITORY_CONTROLS_SHA256" "$REPOSITORY_CONTROLS_NAME" + printf '%s %s\n' "$RELEASE_MANIFEST_SHA256" "$RELEASE_MANIFEST_NAME" + } > SHA256SUMS + + - name: Attest release checksums + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: SHA256SUMS - name: Create GitHub release env: @@ -256,6 +271,9 @@ jobs: CRATE_NAME: ${{ steps.package.outputs.crate_name }} CRATE_PATH: ${{ steps.package.outputs.crate_path }} CRATE_SHA256: ${{ steps.package.outputs.crate_sha256 }} + SOURCE_NAME: ${{ steps.package.outputs.source_name }} + SOURCE_PATH: ${{ steps.package.outputs.source_path }} + SOURCE_SHA256: ${{ steps.package.outputs.source_sha256 }} CT_BUNDLE_NAME: ${{ steps.ct_evidence.outputs.bundle_name }} CT_BUNDLE_PATH: ${{ steps.ct_evidence.outputs.bundle_path }} CT_BUNDLE_SHA256: ${{ steps.ct_evidence.outputs.bundle_sha256 }} @@ -266,15 +284,24 @@ jobs: REPOSITORY_CONTROLS_NAME: ${{ steps.repository_controls.outputs.evidence_name }} REPOSITORY_CONTROLS_PATH: ${{ steps.repository_controls.outputs.evidence_path }} REPOSITORY_CONTROLS_SHA256: ${{ steps.repository_controls.outputs.evidence_sha256 }} + RELEASE_MANIFEST_NAME: ${{ steps.release_manifest.outputs.manifest_name }} + RELEASE_MANIFEST_PATH: ${{ steps.release_manifest.outputs.manifest_path }} + RELEASE_MANIFEST_SHA256: ${{ steps.release_manifest.outputs.manifest_sha256 }} run: | set -euo pipefail notes_file="$(mktemp)" cat > "$notes_file" </dev/null 2>&1; then - gh release upload "$GITHUB_REF_NAME" \ - "$CRATE_PATH" "$CT_BUNDLE_PATH" "$REPOSITORY_CONTROLS_PATH" SHA256SUMS --clobber - gh release edit "$GITHUB_REF_NAME" \ - --title "rscrypto $GITHUB_REF_NAME" \ - --notes-file "$notes_file" - else - gh release create "$GITHUB_REF_NAME" \ - "$CRATE_PATH" "$CT_BUNDLE_PATH" "$REPOSITORY_CONTROLS_PATH" SHA256SUMS \ - --verify-tag \ - --title "rscrypto $GITHUB_REF_NAME" \ - --notes-file "$notes_file" + scripts/ci/publish-immutable-release.sh \ + --tag "$GITHUB_REF_NAME" \ + --title "rscrypto $GITHUB_REF_NAME" \ + --notes "$notes_file" \ + --asset "$CRATE_PATH" \ + --asset "$SOURCE_PATH" \ + --asset "$CT_BUNDLE_PATH" \ + --asset "$REPOSITORY_CONTROLS_PATH" \ + --asset "$RELEASE_MANIFEST_PATH" \ + --asset SHA256SUMS \ + --stable-asset "$CRATE_PATH" \ + --stable-asset "$SOURCE_PATH" + + - name: Authenticate with crates.io + if: steps.existing.outputs.published != 'true' + id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + + - name: Publish to crates.io + if: steps.existing.outputs.published != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + CRATE_PATH: ${{ steps.package.outputs.crate_path }} + CRATE_SHA256: ${{ steps.package.outputs.crate_sha256 }} + run: | + set -euo pipefail + before_sha="$(sha256sum "$CRATE_PATH" | awk '{print $1}')" + cargo publish -p rscrypto --locked + after_sha="$(sha256sum "$CRATE_PATH" | awk '{print $1}')" + if [[ "$before_sha" != "$after_sha" || "$after_sha" != "$CRATE_SHA256" ]]; then + echo "cargo publish changed the package artifact" >&2 + echo "before: $before_sha" >&2 + echo "after: $after_sha" >&2 + echo "expected: $CRATE_SHA256" >&2 + exit 1 fi + - name: Verify crates.io package + env: + CRATE_SHA256: ${{ steps.package.outputs.crate_sha256 }} + CRATE_VERSION: ${{ steps.package.outputs.crate_version }} + run: | + set -euo pipefail + url="https://crates.io/api/v1/crates/rscrypto/${CRATE_VERSION}/download" + for attempt in {1..18}; do + if curl -fsSL -A "rscrypto-release/${CRATE_VERSION} (https://github.com/loadingalias/rscrypto)" "$url" -o published.crate; then + published_sha="$(sha256sum published.crate | awk '{print $1}')" + if [[ "$published_sha" == "$CRATE_SHA256" ]]; then + exit 0 + fi + echo "crates.io sha256 mismatch for rscrypto ${CRATE_VERSION}" >&2 + echo "expected: $CRATE_SHA256" >&2 + echo "actual: $published_sha" >&2 + exit 1 + fi + echo "crates.io package not available yet; retry $attempt/18" + sleep 10 + done + echo "timed out waiting for rscrypto ${CRATE_VERSION} on crates.io" >&2 + exit 1 + evidence-gate: name: Release Evidence Gate needs: preflight diff --git a/docs/constant-time.md b/docs/constant-time.md index 90388c16..5185035c 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -122,10 +122,11 @@ for audit-constrained builds, but it is not a proof by itself. ## Evidence -The source manifest is never sufficient to establish a release claim. The -matching signed GitHub release must contain all of: +Source inspection and `ct.toml` are not sufficient to establish a release claim. The matching signed GitHub release +must contain all of: -- `rscrypto-X.Y.Z.crate` and `SHA256SUMS`. +- The attested release manifest, source archive, crate, and `SHA256SUMS` binding the release tag, commit, toolchain, + and artifacts. - An attested `rscrypto-X.Y.Z-ct-evidence.tar.gz` built from the same release commit. - `CT-EVIDENCE-BUNDLE.json`, naming the version, full commit, release profile, @@ -184,11 +185,20 @@ exact GitHub release, then verify both attestations and hashes: ```bash gh release download vX.Y.Z --repo loadingalias/rscrypto \ -p 'rscrypto-X.Y.Z.crate' \ + -p 'rscrypto-X.Y.Z-source.tar.gz' \ -p 'rscrypto-X.Y.Z-ct-evidence.tar.gz' \ + -p 'rscrypto-X.Y.Z-repository-controls.json' \ + -p 'rscrypto-X.Y.Z-release-manifest.json' \ -p SHA256SUMS sha256sum --check SHA256SUMS +gh release verify vX.Y.Z --repo loadingalias/rscrypto +gh release verify-asset vX.Y.Z rscrypto-X.Y.Z-ct-evidence.tar.gz --repo loadingalias/rscrypto gh attestation verify rscrypto-X.Y.Z.crate --repo loadingalias/rscrypto +gh attestation verify rscrypto-X.Y.Z-source.tar.gz --repo loadingalias/rscrypto gh attestation verify rscrypto-X.Y.Z-ct-evidence.tar.gz --repo loadingalias/rscrypto +gh attestation verify rscrypto-X.Y.Z-repository-controls.json --repo loadingalias/rscrypto +gh attestation verify rscrypto-X.Y.Z-release-manifest.json --repo loadingalias/rscrypto +gh attestation verify SHA256SUMS --repo loadingalias/rscrypto mkdir ct-evidence && tar -xzf rscrypto-X.Y.Z-ct-evidence.tar.gz -C ct-evidence (cd ct-evidence && sha256sum --check CT-EVIDENCE-MANIFEST.txt) ``` diff --git a/docs/release.md b/docs/release.md index 00fd7e82..3a46dbbd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -26,9 +26,16 @@ Configure the GitHub repository: 1. Create the active `protect-main` branch ruleset described by [`.github/rulesets/protect-main.json`](../.github/rulesets/protect-main.json), with no bypass actors. -2. Create an environment named `crates-io`. -3. Add required reviewers for that environment. -4. Keep long-lived crates.io publish tokens out of repository secrets. +2. Create the active `protect-release-tags` tag ruleset described by + [`.github/rulesets/protect-release-tags.json`](../.github/rulesets/protect-release-tags.json), with no bypass + actors. It permits a new `v*` tag but prevents updating or deleting an existing one. +3. In **Settings → General → Releases**, enable release immutability as described by + [`.github/repository-settings/release-immutability.json`](../.github/repository-settings/release-immutability.json). + It applies only to releases published after the setting is enabled. +4. Create an environment named `crates-io`. +5. Add the current maintainer as its required reviewer. While the project has one maintainer, permit self-review; + enable independent review only after a second trusted maintainer exists. +6. Keep long-lived crates.io publish tokens out of repository secrets. The environment name must match both crates.io and [`.github/workflows/release.yaml`](../.github/workflows/release.yaml). If it @@ -154,16 +161,16 @@ Pushing a `vX.Y.Z` tag starts the `Release` workflow. The workflow: against the finalized release version. Before tagging, cargo-rail's automatic release plan uses compatibility analysis to select the required version bump; the extended release check reports the advisory verdict. -6. Builds the `.crate` once with `cargo package --locked` and transfers that - validated artifact to the publish job. -7. Rejects dirty VCS metadata and private or local-only package contents. +6. Builds the `.crate` once with `cargo package --locked` and creates a deterministic source archive from the exact + tag commit, then transfers both validated artifacts to the publish job. +7. Rejects dirty or mismatched VCS metadata, private or local-only package contents, and source archives that cannot + be reproduced from the tag commit. 8. Waits for the `CI` workflow on the same commit to pass. -9. Verifies the transferred artifact's SHA-256, then attests it with GitHub - build provenance. +9. Verifies both transferred SHA-256 values, then attests the crate and source archive with GitHub build provenance. 10. Compares every rule visible to the restricted workflow token with the - checked-in policy and attests the resulting repository controls JSON. GitHub - redacts bypass actors from this token; the artifact labels that field as - redacted instead of pretending the workflow reverified the pre-tag gate. + checked-in default-branch and release-tag policies and attests the resulting repository controls JSON. GitHub + redacts bypass actors from this token; the artifact labels those fields as redacted instead of pretending the + workflow reverified the pre-tag gate. 11. Requires successful Weekly CT/RSA evidence and RISC-V native/CT evidence from the exact tag commit or the same mechanically proven release-only ancestor, then downloads raw CT artifacts from both runs. The tag workflow @@ -172,12 +179,15 @@ Pushing a `vX.Y.Z` tag starts the `Release` workflow. The workflow: tool versions, timing coverage, BINSEC coverage, and raw artifact hashes. The bundle separately records the release commit and evidence commit. 13. Attests the CT evidence bundle with GitHub build provenance. -14. Uses crates.io Trusted Publishing to get a temporary publish token. -15. Publishes with `cargo publish -p rscrypto --locked`. -16. Downloads the crate from crates.io and verifies the SHA-256 against the - attested package. -17. Creates or updates the GitHub Release with the crate, CT evidence bundle, - repository controls JSON, and `SHA256SUMS`. +14. Writes and attests a release identity manifest binding the tag, commit, tree, pinned and active Rust toolchain, + Cargo lockfile, release workflow, source archive, crate, CT evidence, and repository controls. +15. Writes and attests `SHA256SUMS` for every published release asset. +16. Creates a draft GitHub Release, attaches every asset, publishes it as an immutable release, and verifies GitHub's + release attestation before any irreversible registry change. A rerun may repair an unpublished draft, but an + already-published release is verified instead of modified. +17. Uses crates.io Trusted Publishing to get a temporary publish token. +18. Publishes with `cargo publish -p rscrypto --locked`. +19. Downloads the crate from crates.io and verifies the SHA-256 against the attested package. The publish job uses the `crates-io` GitHub environment. GitHub requests approval only after preflight and CI have passed, and before the OIDC token is @@ -185,9 +195,10 @@ issued. ## Recovery -Re-running the workflow is safe after a partial failure. If crates.io already -has the version, the workflow downloads it and compares SHA-256 before touching -the GitHub Release. A mismatch is a hard stop. +Re-running the workflow is safe after a partial failure. If crates.io already has the version, the workflow downloads +it and compares SHA-256 before touching the GitHub Release. A draft release can be repaired and then published. A +published immutable release is never overwritten; the workflow verifies its release attestation and stable crate and +source assets before publishing to crates.io. A mismatch is a hard stop. If the signed-tag key changes, update `.github/allowed-signers` in a reviewed commit before creating the next release tag. @@ -199,20 +210,28 @@ Consumers can verify the GitHub Release artifact: ```bash gh release download vX.Y.Z --repo loadingalias/rscrypto \ -p 'rscrypto-X.Y.Z.crate' \ + -p 'rscrypto-X.Y.Z-source.tar.gz' \ -p 'rscrypto-X.Y.Z-ct-evidence.tar.gz' \ -p 'rscrypto-X.Y.Z-repository-controls.json' \ + -p 'rscrypto-X.Y.Z-release-manifest.json' \ -p SHA256SUMS sha256sum --check SHA256SUMS +gh release verify vX.Y.Z --repo loadingalias/rscrypto +gh release verify-asset vX.Y.Z rscrypto-X.Y.Z.crate --repo loadingalias/rscrypto +gh release verify-asset vX.Y.Z rscrypto-X.Y.Z-source.tar.gz --repo loadingalias/rscrypto gh attestation verify rscrypto-X.Y.Z.crate --repo loadingalias/rscrypto +gh attestation verify rscrypto-X.Y.Z-source.tar.gz --repo loadingalias/rscrypto gh attestation verify rscrypto-X.Y.Z-ct-evidence.tar.gz --repo loadingalias/rscrypto gh attestation verify rscrypto-X.Y.Z-repository-controls.json --repo loadingalias/rscrypto +gh attestation verify rscrypto-X.Y.Z-release-manifest.json --repo loadingalias/rscrypto +gh attestation verify SHA256SUMS --repo loadingalias/rscrypto mkdir ct-evidence && tar -xzf rscrypto-X.Y.Z-ct-evidence.tar.gz -C ct-evidence (cd ct-evidence && sha256sum --check CT-EVIDENCE-MANIFEST.txt) ``` -The crate downloaded from crates.io should have the same SHA-256 as the -attested release artifact. The repository controls JSON records the expected -policy, live ruleset, effective rules on the default branch, capture time, and -release commit. `validation.bypass_actors.status` states whether bypass actors -were verified or redacted by GitHub. The JSON is evidence of the release-time -configuration, not a claim that GitHub settings cannot change later. +The crate downloaded from crates.io should have the same SHA-256 as the attested release artifact. The release +identity manifest is the machine-readable join between the release's source, artifacts, evidence, and toolchain. The +repository controls JSON records the expected policies, immutable-release setting, live branch and tag rulesets, +effective rules on the default branch, capture time, and release commit. Its validation fields state whether each +bypass list and the immutable-release setting were visible to the capturing token. The JSON is evidence of the +release-time configuration, not a claim that GitHub settings cannot change later. diff --git a/justfile b/justfile index 9ea7e15a..540b543e 100644 --- a/justfile +++ b/justfile @@ -254,6 +254,8 @@ check-actions: @scripts/ci/release-ci-check-test.sh @scripts/ci/release-evidence-check-test.sh @scripts/ci/repository-controls-evidence-test.sh + @scripts/ci/release-identity-test.sh + @scripts/ci/publish-immutable-release-test.sh @scripts/ci/release-recipes-test.sh @actionlint @zizmor .github/workflows .github/actions diff --git a/scripts/README.md b/scripts/README.md index 8b76949d..dd426b9b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,6 +31,11 @@ its caller. | `ci/release-evidence-check-test.sh` | `just check-actions` | | `ci/repository-controls-evidence.sh` | `just check-repository-controls`, `release.yaml`, `ci/repository-controls-evidence-test.sh` | | `ci/repository-controls-evidence-test.sh` | `just check-actions` | +| `ci/package-release-source.sh` | `release.yaml`, `ci/release-identity-test.sh` | +| `ci/write-release-manifest.sh` | `release.yaml`, `ci/release-identity-test.sh` | +| `ci/release-identity-test.sh` | `just check-actions` | +| `ci/publish-immutable-release.sh` | `release.yaml`, `ci/publish-immutable-release-test.sh` | +| `ci/publish-immutable-release-test.sh` | `just check-actions` | | `ci/release-recipes-test.sh` | `just check-actions` | | `ci/upgrade-actions.sh` | `scripts/update/update-all.sh` | | `ci/upgrade-actions-test.sh` | `just check-actions` | diff --git a/scripts/ci/check-ci-ownership-test.sh b/scripts/ci/check-ci-ownership-test.sh index 31d47b58..afba16de 100755 --- a/scripts/ci/check-ci-ownership-test.sh +++ b/scripts/ci/check-ci-ownership-test.sh @@ -12,6 +12,7 @@ make_fixture() { mkdir -p "$fixture/.github" "$fixture/.config" "$fixture/scripts/check" "$fixture/scripts/test" cp -R "$REPO_ROOT/.github/workflows" "$fixture/.github/workflows" cp -R "$REPO_ROOT/.github/rulesets" "$fixture/.github/rulesets" + cp -R "$REPO_ROOT/.github/repository-settings" "$fixture/.github/repository-settings" cp "$REPO_ROOT/.github/dependabot.yaml" "$fixture/.github/dependabot.yaml" cp "$REPO_ROOT/.config/target-matrix.json" "$fixture/.config/target-matrix.json" cp -R "$REPO_ROOT/scripts/ci" "$fixture/scripts/ci" @@ -109,6 +110,28 @@ make_fixture "$missing_repository_policy" rm "$missing_repository_policy/.github/rulesets/protect-main.json" expect_failure "$missing_repository_policy" "missing repository ruleset policy" +missing_release_tag_policy="$TMP_ROOT/missing-release-tag-policy" +make_fixture "$missing_release_tag_policy" +rm "$missing_release_tag_policy/.github/rulesets/protect-release-tags.json" +expect_failure "$missing_release_tag_policy" "missing release-tag ruleset policy" + +missing_release_immutability_policy="$TMP_ROOT/missing-release-immutability-policy" +make_fixture "$missing_release_immutability_policy" +rm "$missing_release_immutability_policy/.github/repository-settings/release-immutability.json" +expect_failure "$missing_release_immutability_policy" "missing release immutability policy" + +missing_source_archive="$TMP_ROOT/missing-source-archive" +make_fixture "$missing_source_archive" +sed -i.bak '/package-release-source\.sh/d' "$missing_source_archive/scripts/ci/release-preflight.sh" +rm -f "$missing_source_archive/scripts/ci/release-preflight.sh.bak" +expect_failure "$missing_source_archive" "release without deterministic source archive" + +missing_release_manifest="$TMP_ROOT/missing-release-manifest" +make_fixture "$missing_release_manifest" +sed -i.bak '/write-release-manifest\.sh/d' "$missing_release_manifest/.github/workflows/release.yaml" +rm -f "$missing_release_manifest/.github/workflows/release.yaml.bak" +expect_failure "$missing_release_manifest" "release without identity manifest" + missing_riscv_workflow="$TMP_ROOT/missing-riscv-workflow" make_fixture "$missing_riscv_workflow" rm "$missing_riscv_workflow/.github/workflows/riscv.yaml" diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index 20eb337a..e0114e51 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -31,9 +31,16 @@ CI_CHECK="$ROOT/scripts/ci/ci-check.sh" RELEASE_PREFLIGHT="$ROOT/scripts/ci/release-preflight.sh" RELEASE_CI="$ROOT/scripts/ci/release-ci-check.sh" RELEASE_EVIDENCE="$ROOT/scripts/ci/release-evidence-check.sh" +RELEASE_SOURCE="$ROOT/scripts/ci/package-release-source.sh" +RELEASE_MANIFEST="$ROOT/scripts/ci/write-release-manifest.sh" +RELEASE_IDENTITY_TEST="$ROOT/scripts/ci/release-identity-test.sh" +PUBLISH_RELEASE="$ROOT/scripts/ci/publish-immutable-release.sh" +PUBLISH_RELEASE_TEST="$ROOT/scripts/ci/publish-immutable-release-test.sh" REPOSITORY_CONTROLS="$ROOT/scripts/ci/repository-controls-evidence.sh" REPOSITORY_CONTROLS_TEST="$ROOT/scripts/ci/repository-controls-evidence-test.sh" REPOSITORY_POLICY="$ROOT/.github/rulesets/protect-main.json" +RELEASE_TAG_POLICY="$ROOT/.github/rulesets/protect-release-tags.json" +RELEASE_IMMUTABILITY_POLICY="$ROOT/.github/repository-settings/release-immutability.json" DEPENDABOT="$ROOT/.github/dependabot.yaml" fail() { @@ -91,9 +98,16 @@ require_file "$CI_CHECK" require_file "$RELEASE_PREFLIGHT" require_file "$RELEASE_CI" require_file "$RELEASE_EVIDENCE" +require_file "$RELEASE_SOURCE" +require_file "$RELEASE_MANIFEST" +require_file "$RELEASE_IDENTITY_TEST" +require_file "$PUBLISH_RELEASE" +require_file "$PUBLISH_RELEASE_TEST" require_file "$REPOSITORY_CONTROLS" require_file "$REPOSITORY_CONTROLS_TEST" require_file "$REPOSITORY_POLICY" +require_file "$RELEASE_TAG_POLICY" +require_file "$RELEASE_IMMUTABILITY_POLICY" require_file "$DEPENDABOT" [[ $(yq eval '.version' "$DEPENDABOT") == "2" ]] || fail "Dependabot config must use version 2" @@ -177,6 +191,10 @@ grep -Fq 'scripts/ci/release-evidence-check.sh --commit "$GITHUB_SHA"' "$RELEASE || fail "release must require paired Weekly and RISC-V evidence from one valid commit" grep -Fq 'scripts/ci/repository-controls-evidence.sh' "$RELEASE" \ || fail "release must capture the live repository controls" +grep -Fq 'scripts/ci/package-release-source.sh' "$RELEASE_PREFLIGHT" \ + || fail "release preflight must build the exact-commit source archive" +grep -Fq 'scripts/ci/write-release-manifest.sh' "$RELEASE" \ + || fail "release must bind artifacts and toolchain metadata in one identity manifest" grep -Fq -- '--allow-redacted-bypass' "$RELEASE" \ || fail "release must explicitly acknowledge GitHub's workflow-token bypass redaction" # shellcheck disable=SC2016 # GitHub expression is an intentional literal workflow contract. @@ -187,8 +205,52 @@ grep -Fq 'REPOSITORY_CONTROLS_SHA256' "$RELEASE" \ # shellcheck disable=SC2016 # Workflow shell variable is an intentional literal contract. grep -Fq '"$REPOSITORY_CONTROLS_PATH"' "$RELEASE" \ || fail "release must publish the repository controls evidence" +# shellcheck disable=SC2016 # GitHub expression is an intentional literal workflow contract. +grep -Fq 'subject-path: ${{ steps.package.outputs.source_path }}' "$RELEASE" \ + || fail "release must attest the deterministic source archive" +# shellcheck disable=SC2016 # GitHub expression is an intentional literal workflow contract. +grep -Fq 'subject-path: ${{ steps.release_manifest.outputs.manifest_path }}' "$RELEASE" \ + || fail "release must attest the identity manifest" +grep -Fq 'subject-path: SHA256SUMS' "$RELEASE" \ + || fail "release must attest its checksum set" +grep -Fq 'SOURCE_SHA256' "$RELEASE" \ + || fail "release must checksum the deterministic source archive" +grep -Fq 'RELEASE_MANIFEST_SHA256' "$RELEASE" \ + || fail "release must checksum the identity manifest" +grep -Fq 'scripts/ci/publish-immutable-release.sh' "$RELEASE" \ + || fail "release workflow must use the tested immutable publication state machine" +immutable_release_line=$(grep -nF 'scripts/ci/publish-immutable-release.sh' "$RELEASE" | cut -d: -f1) +crates_publish_line=$(grep -nF 'cargo publish -p rscrypto --locked' "$RELEASE" | cut -d: -f1) +[[ "$immutable_release_line" -lt "$crates_publish_line" ]] \ + || fail "release immutability must be verified before crates.io publication" +grep -Fq 'gh release create "$tag"' "$PUBLISH_RELEASE" \ + || fail "immutable publication must create the GitHub release" +grep -Fq -- '--draft' "$PUBLISH_RELEASE" \ + || fail "release assets must be assembled in a draft before immutable publication" +grep -Fq 'gh release verify "$tag"' "$PUBLISH_RELEASE" \ + || fail "release workflow must verify GitHub's immutable release attestation" +grep -Fq 'gh release verify-asset "$tag"' "$PUBLISH_RELEASE" \ + || fail "release workflow must verify assets against the immutable release" +grep -Fq -- "--jq '.assets[].name'" "$PUBLISH_RELEASE" \ + || fail "release workflow must reject missing or unexpected release assets" +grep -Fq -- '--stable-asset "$CRATE_PATH"' "$RELEASE" \ + || fail "release reruns must verify the crates.io-bound package asset" +grep -Fq -- '--stable-asset "$SOURCE_PATH"' "$RELEASE" \ + || fail "release reruns must verify the deterministic source archive" grep -Fq '.github/rulesets/protect-main.json' "$REPOSITORY_CONTROLS" \ || fail "repository controls evidence must validate the checked-in policy" +grep -Fq '.github/rulesets/protect-release-tags.json' "$REPOSITORY_CONTROLS" \ + || fail "repository controls evidence must validate immutable release tags" +jq -e ' + .target == "tag" + and .enforcement == "active" + and .bypass_actors == [] + and ([.rules[].type] | sort) == ["deletion", "update"] +' "$RELEASE_TAG_POLICY" >/dev/null || fail "release tags must reject updates and deletion without bypass" +jq -e '.enabled == true and (keys == ["enabled"])' "$RELEASE_IMMUTABILITY_POLICY" >/dev/null \ + || fail "repository policy must require immutable releases" +grep -Fq 'repos/$repo/immutable-releases' "$REPOSITORY_CONTROLS" \ + || fail "repository controls evidence must validate immutable releases before tagging" grep -Fq 'current_user_can_bypass == "never"' "$REPOSITORY_CONTROLS" \ || fail "repository controls evidence must reject bypass access" grep -Fq 'run-id: ${{ needs.evidence-gate.outputs.weekly_run_id }}' "$RELEASE" \ diff --git a/scripts/ci/package-release-source.sh b/scripts/ci/package-release-source.sh new file mode 100755 index 00000000..53c21307 --- /dev/null +++ b/scripts/ci/package-release-source.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: scripts/ci/package-release-source.sh --version VERSION --tag TAG --commit SHA [options] + +Build a deterministic source archive from an annotated release tag. + +Options: + --version VERSION Unprefixed crate version + --tag TAG Release tag (must equal vVERSION) + --commit SHA Full commit expected beneath TAG + --out DIR Output directory (default: target/package) + --root PATH Repository root (default: current repository) + -h, --help Show this help +EOF +} + +version="" +tag="" +commit="" +out_dir="target/package" +root="$(git rev-parse --show-toplevel)" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + version=${2:?missing value for --version} + shift 2 + ;; + --tag) + tag=${2:?missing value for --tag} + shift 2 + ;; + --commit) + commit=${2:?missing value for --commit} + shift 2 + ;; + --out) + out_dir=${2:?missing value for --out} + shift 2 + ;; + --root) + root=${2:?missing value for --root} + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] || { + echo "source archive error: --version must be an unprefixed SemVer version" >&2 + exit 2 +} +[[ "$tag" == "v$version" ]] || { + echo "source archive error: tag $tag does not match version $version" >&2 + exit 1 +} +[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || { + echo "source archive error: --commit must be a full lowercase Git commit" >&2 + exit 2 +} + +git -C "$root" cat-file -e "$commit^{commit}" 2>/dev/null || { + echo "source archive error: commit is not present: $commit" >&2 + exit 1 +} +git -C "$root" rev-parse -q --verify "$tag^{tag}" >/dev/null || { + echo "source archive error: release ref must be an annotated tag: $tag" >&2 + exit 1 +} +tag_commit=$(git -C "$root" rev-parse "$tag^{commit}") +[[ "$tag_commit" == "$commit" ]] || { + echo "source archive error: tag $tag resolves to $tag_commit, not $commit" >&2 + exit 1 +} + +mkdir -p "$out_dir" +archive_name="rscrypto-${version}-source.tar.gz" +archive_path="$out_dir/$archive_name" +archive_tmp="${archive_path}.tmp.$$" +contents=$(mktemp) +trap 'rm -f "$archive_tmp" "$contents"' EXIT + +git -C "$root" archive --format=tar --prefix="rscrypto-${version}/" "$commit" | gzip -n -9 > "$archive_tmp" +tar -tzf "$archive_tmp" > "$contents" + +if awk -v prefix="rscrypto-${version}/" 'index($0, prefix) != 1 { exit 1 }' "$contents"; then + : +else + echo "source archive error: archive contains a path outside rscrypto-${version}/" >&2 + exit 1 +fi + +for required in Cargo.toml Cargo.lock rust-toolchain.toml .github/workflows/release.yaml; do + grep -Fxq "rscrypto-${version}/$required" "$contents" || { + echo "source archive error: archive is missing $required" >&2 + exit 1 + } +done + +mv "$archive_tmp" "$archive_path" +trap 'rm -f "$contents"' EXIT + +if command -v sha256sum >/dev/null 2>&1; then + source_sha256=$(sha256sum "$archive_path" | awk '{print $1}') +else + source_sha256=$(shasum -a 256 "$archive_path" | awk '{print $1}') +fi + +if [[ -n ${GITHUB_OUTPUT:-} ]]; then + { + echo "source_path=$archive_path" + echo "source_name=$archive_name" + echo "source_sha256=$source_sha256" + } >> "$GITHUB_OUTPUT" +fi + +echo "source archive: $archive_path" +echo "sha256: $source_sha256" diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index f72cea5b..ba41b73a 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -183,7 +183,7 @@ needs_actions_check() { return 0 fi - changed_file_matches '^\.config/target-matrix\.json$|^\.github/actions-lock\.yaml$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/rulesets/.*\.json$|^scripts/ci/(pin-actions|check-ci-ownership|check-ci-ownership-test|release-ci-check|release-ci-check-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|release-recipes-test)\.sh$' + changed_file_matches '^\.config/target-matrix\.json$|^\.github/actions-lock\.yaml$|^\.github/(workflows|actions)/.*\.ya?ml$|^\.github/(rulesets|repository-settings)/.*\.json$|^scripts/ci/(pin-actions|check-ci-ownership|check-ci-ownership-test|release-ci-check|release-ci-check-test|release-evidence-check|release-evidence-check-test|repository-controls-evidence|repository-controls-evidence-test|package-release-source|write-release-manifest|release-identity-test|publish-immutable-release|publish-immutable-release-test|release-recipes-test)\.sh$' } needs_host_checks() { diff --git a/scripts/ci/publish-immutable-release-test.sh b/scripts/ci/publish-immutable-release-test.sh new file mode 100755 index 00000000..57b3828a --- /dev/null +++ b/scripts/ci/publish-immutable-release-test.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUBLISHER="$SCRIPT_DIR/publish-immutable-release.sh" +TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$TMP_ROOT"' EXIT + +mkdir -p "$TMP_ROOT/bin" "$TMP_ROOT/artifacts" +cat > "$TMP_ROOT/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%q ' "$@" >> "$FAKE_GH_LOG" +printf '\n' >> "$FAKE_GH_LOG" + +[[ ${1:-} == "release" ]] || exit 2 +operation=${2:-} +shift 2 + +case "$operation" in + view) + [[ $(cat "$FAKE_GH_STATE") != "absent" ]] || exit 1 + if [[ " $* " == *" --json isDraft "* ]]; then + [[ $(cat "$FAKE_GH_STATE") == "draft" ]] && echo true || echo false + elif [[ " $* " == *" --json assets "* ]]; then + cat "$FAKE_GH_ASSETS" + fi + ;; + create) + echo draft > "$FAKE_GH_STATE" + ;; + upload) + [[ $(cat "$FAKE_GH_STATE") == "draft" ]] || exit 1 + shift + while [[ $# -gt 0 ]]; do + if [[ $1 != "--clobber" ]]; then + basename "$1" >> "$FAKE_GH_ASSETS" + fi + shift + done + LC_ALL=C sort -u "$FAKE_GH_ASSETS" -o "$FAKE_GH_ASSETS" + ;; + edit) + [[ $(cat "$FAKE_GH_STATE") == "draft" ]] || exit 1 + [[ " $* " == *" --draft=false "* ]] || exit 1 + echo published > "$FAKE_GH_STATE" + ;; + verify) + [[ $(cat "$FAKE_GH_STATE") == "published" && ${FAKE_GH_VERIFY_FAIL:-false} != true ]] + ;; + verify-asset) + [[ $(cat "$FAKE_GH_STATE") == "published" ]] || exit 1 + asset_name=$(basename "${2:?missing asset path}") + grep -Fxq "$asset_name" "$FAKE_GH_ASSETS" + [[ "$asset_name" != "${FAKE_GH_BAD_ASSET:-}" ]] + ;; + *) + exit 2 + ;; +esac +EOF +chmod +x "$TMP_ROOT/bin/gh" + +export PATH="$TMP_ROOT/bin:$PATH" +export FAKE_GH_LOG="$TMP_ROOT/gh.log" +export FAKE_GH_STATE="$TMP_ROOT/state" +export FAKE_GH_ASSETS="$TMP_ROOT/assets" +export RSCRYPTO_RELEASE_VERIFY_ATTEMPTS=1 +export RSCRYPTO_RELEASE_VERIFY_DELAY=0 + +for name in rscrypto-1.2.3.crate rscrypto-1.2.3-source.tar.gz SHA256SUMS; do + printf '%s\n' "$name" > "$TMP_ROOT/artifacts/$name" +done +printf 'notes\n' > "$TMP_ROOT/notes.md" + +publish() { + /bin/bash "$PUBLISHER" \ + --tag v1.2.3 \ + --title "rscrypto v1.2.3" \ + --notes "$TMP_ROOT/notes.md" \ + --asset "$TMP_ROOT/artifacts/rscrypto-1.2.3.crate" \ + --asset "$TMP_ROOT/artifacts/rscrypto-1.2.3-source.tar.gz" \ + --asset "$TMP_ROOT/artifacts/SHA256SUMS" \ + --stable-asset "$TMP_ROOT/artifacts/rscrypto-1.2.3.crate" \ + --stable-asset "$TMP_ROOT/artifacts/rscrypto-1.2.3-source.tar.gz" +} + +if /bin/bash "$PUBLISHER" \ + --tag v1.2.3 \ + --title "rscrypto v1.2.3" \ + --notes "$TMP_ROOT/notes.md" \ + --asset "$TMP_ROOT/artifacts/rscrypto-1.2.3.crate" \ + --asset "$TMP_ROOT/artifacts/rscrypto-1.2.3.crate" \ + --stable-asset "$TMP_ROOT/artifacts/rscrypto-1.2.3.crate" >/dev/null 2>&1; then + echo "immutable release publisher accepted duplicate asset names" >&2 + exit 1 +fi + +: > "$FAKE_GH_LOG" +: > "$FAKE_GH_ASSETS" +echo absent > "$FAKE_GH_STATE" +publish >/dev/null +[[ $(cat "$FAKE_GH_STATE") == "published" ]] +diff -u \ + <(printf '%s\n' SHA256SUMS rscrypto-1.2.3-source.tar.gz rscrypto-1.2.3.crate) \ + "$FAKE_GH_ASSETS" +grep -Fq 'release create' "$FAKE_GH_LOG" +grep -Fq 'release upload' "$FAKE_GH_LOG" +grep -Fq 'release edit' "$FAKE_GH_LOG" + +: > "$FAKE_GH_LOG" +printf 'unexpected.bin\n' > "$FAKE_GH_ASSETS" +echo draft > "$FAKE_GH_STATE" +if publish >/dev/null 2>&1; then + echo "immutable release publisher accepted an unexpected draft asset" >&2 + exit 1 +fi +[[ $(cat "$FAKE_GH_STATE") == "draft" ]] +if grep -Fq 'release edit' "$FAKE_GH_LOG"; then + echo "immutable release publisher published an invalid draft" >&2 + exit 1 +fi + +: > "$FAKE_GH_LOG" +printf '%s\n' SHA256SUMS rscrypto-1.2.3-source.tar.gz rscrypto-1.2.3.crate > "$FAKE_GH_ASSETS" +echo published > "$FAKE_GH_STATE" +publish >/dev/null +if grep -Eq 'release (create|upload|edit)' "$FAKE_GH_LOG"; then + echo "immutable release publisher modified an existing published release" >&2 + exit 1 +fi +[[ $(grep -c 'release verify-asset' "$FAKE_GH_LOG") -eq 2 ]] + +: > "$FAKE_GH_LOG" +printf '%s\n' rscrypto-1.2.3-source.tar.gz rscrypto-1.2.3.crate > "$FAKE_GH_ASSETS" +echo published > "$FAKE_GH_STATE" +if publish >/dev/null 2>&1; then + echo "immutable release publisher accepted a missing published asset" >&2 + exit 1 +fi + +: > "$FAKE_GH_LOG" +printf '%s\n' SHA256SUMS rscrypto-1.2.3-source.tar.gz rscrypto-1.2.3.crate > "$FAKE_GH_ASSETS" +echo published > "$FAKE_GH_STATE" +if FAKE_GH_BAD_ASSET=rscrypto-1.2.3.crate publish >/dev/null 2>&1; then + echo "immutable release publisher accepted a mismatched stable asset" >&2 + exit 1 +fi + +: > "$FAKE_GH_LOG" +echo published > "$FAKE_GH_STATE" +if FAKE_GH_VERIFY_FAIL=true publish >/dev/null 2>&1; then + echo "immutable release publisher accepted a mutable published release" >&2 + exit 1 +fi + +echo "Immutable release publication regression tests passed" diff --git a/scripts/ci/publish-immutable-release.sh b/scripts/ci/publish-immutable-release.sh new file mode 100755 index 00000000..1ab5bb50 --- /dev/null +++ b/scripts/ci/publish-immutable-release.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: scripts/ci/publish-immutable-release.sh [options] + +Required: + --tag TAG Existing annotated release tag + --title TITLE GitHub Release title + --notes PATH Release notes file + --asset PATH Asset to publish; repeat for every asset + --stable-asset PATH Asset that must match on a published-release rerun; repeat as needed + +An absent release is assembled as a draft and then published. An existing +draft is repaired only when its final asset set is exact. An existing published +release is never modified and must have a valid immutable-release attestation. +EOF +} + +tag="" +title="" +notes="" +assets=() +stable_assets=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) tag=${2:?missing value for --tag}; shift 2 ;; + --title) title=${2:?missing value for --title}; shift 2 ;; + --notes) notes=${2:?missing value for --notes}; shift 2 ;; + --asset) assets+=("${2:?missing value for --asset}"); shift 2 ;; + --stable-asset) stable_assets+=("${2:?missing value for --stable-asset}"); shift 2 ;; + -h | --help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +[[ -n "$tag" && -n "$title" && -f "$notes" ]] || { + usage + exit 2 +} +(( ${#assets[@]} > 0 )) || { + echo "immutable release error: at least one --asset is required" >&2 + exit 2 +} +(( ${#stable_assets[@]} > 0 )) || { + echo "immutable release error: at least one --stable-asset is required" >&2 + exit 2 +} + +asset_names=() +for asset in "${assets[@]}"; do + [[ -f "$asset" ]] || { + echo "immutable release error: asset is missing: $asset" >&2 + exit 1 + } + asset_names+=("$(basename "$asset")") +done +duplicate_name=$(printf '%s\n' "${asset_names[@]}" | LC_ALL=C sort | uniq -d | head -n 1) +[[ -z "$duplicate_name" ]] || { + echo "immutable release error: duplicate asset name: $duplicate_name" >&2 + exit 1 +} + +for asset in "${stable_assets[@]}"; do + [[ -f "$asset" ]] || { + echo "immutable release error: stable asset is missing: $asset" >&2 + exit 1 + } + stable_present=false + for published_asset in "${assets[@]}"; do + if [[ "$published_asset" == "$asset" ]]; then + stable_present=true + break + fi + done + [[ "$stable_present" == true ]] || { + echo "immutable release error: stable asset must also be a published asset: $asset" >&2 + exit 1 + } +done + +verify_attempts=${RSCRYPTO_RELEASE_VERIFY_ATTEMPTS:-18} +verify_delay=${RSCRYPTO_RELEASE_VERIFY_DELAY:-10} +[[ "$verify_attempts" =~ ^[1-9][0-9]*$ && "$verify_delay" =~ ^[0-9]+$ ]] || { + echo "immutable release error: invalid verification retry configuration" >&2 + exit 2 +} + +expected_assets="$(printf '%s\n' "${asset_names[@]}" | LC_ALL=C sort)" + +verify_asset_set() { + local actual_assets + actual_assets="$(gh release view "$tag" --json assets --jq '.assets[].name' | LC_ALL=C sort)" + if [[ "$actual_assets" != "$expected_assets" ]]; then + echo "immutable release error: published asset set differs from the expected artifacts" >&2 + diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") >&2 || true + return 1 + fi +} + +verify_immutable_release() { + local attempt + for ((attempt = 1; attempt <= verify_attempts; attempt++)); do + if gh release verify "$tag" >/dev/null; then + return 0 + fi + echo "immutable release attestation not available yet; retry $attempt/$verify_attempts" + if (( attempt < verify_attempts )); then + sleep "$verify_delay" + fi + done + echo "immutable release error: release is mutable or its attestation was not produced" >&2 + return 1 +} + +release_state="" +if gh release view "$tag" >/dev/null 2>&1; then + release_state=$(gh release view "$tag" --json isDraft --jq .isDraft) +fi + +if [[ "$release_state" == "false" ]]; then + verify_immutable_release + verify_asset_set + for asset in "${stable_assets[@]}"; do + gh release verify-asset "$tag" "$asset" >/dev/null + done + echo "Published immutable release already matches the stable assets" + exit 0 +fi + +if [[ -z "$release_state" ]]; then + gh release create "$tag" \ + --draft \ + --verify-tag \ + --title "$title" \ + --notes-file "$notes" +fi + +gh release upload "$tag" "${assets[@]}" --clobber +verify_asset_set +gh release edit "$tag" \ + --title "$title" \ + --notes-file "$notes" \ + --draft=false + +verify_immutable_release +verify_asset_set +for asset in "${assets[@]}"; do + gh release verify-asset "$tag" "$asset" >/dev/null +done diff --git a/scripts/ci/release-identity-test.sh b/scripts/ci/release-identity-test.sh new file mode 100755 index 00000000..efde9a4a --- /dev/null +++ b/scripts/ci/release-identity-test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SOURCE_PACKAGER="$SCRIPT_DIR/package-release-source.sh" +MANIFEST_WRITER="$SCRIPT_DIR/write-release-manifest.sh" +TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$TMP_ROOT"' EXIT + +fixture="$TMP_ROOT/repository" +mkdir -p "$fixture/.github/workflows" +git -C "$TMP_ROOT" init -q -b main repository +git -C "$fixture" config user.email test@example.com +git -C "$fixture" config user.name "Release Identity Test" + +cat > "$fixture/Cargo.toml" <<'EOF' +[package] +name = "rscrypto" +version = "1.2.3" +edition = "2024" +EOF +cat > "$fixture/Cargo.lock" <<'EOF' +# release identity fixture +version = 4 +EOF +cp "$REPO_ROOT/rust-toolchain.toml" "$fixture/rust-toolchain.toml" +cp "$REPO_ROOT/.github/workflows/release.yaml" "$fixture/.github/workflows/release.yaml" +git -C "$fixture" add . +git -C "$fixture" commit -qm "release fixture" +commit=$(git -C "$fixture" rev-parse HEAD) +git -C "$fixture" tag -a v1.2.3 -m "release v1.2.3" + +artifacts="$TMP_ROOT/artifacts" +mkdir -p "$artifacts" "$TMP_ROOT/package/rscrypto-1.2.3" "$TMP_ROOT/ct" + +"$SOURCE_PACKAGER" \ + --root "$fixture" \ + --version 1.2.3 \ + --tag v1.2.3 \ + --commit "$commit" \ + --out "$artifacts" >/dev/null +"$SOURCE_PACKAGER" \ + --root "$fixture" \ + --version 1.2.3 \ + --tag v1.2.3 \ + --commit "$commit" \ + --out "$TMP_ROOT/reproduced" >/dev/null +cmp "$artifacts/rscrypto-1.2.3-source.tar.gz" "$TMP_ROOT/reproduced/rscrypto-1.2.3-source.tar.gz" + +jq -n --arg commit "$commit" \ + '{git: {sha1: $commit, dirty: false}, path_in_vcs: ""}' \ + > "$TMP_ROOT/package/rscrypto-1.2.3/.cargo_vcs_info.json" +tar -czf "$artifacts/rscrypto-1.2.3.crate" -C "$TMP_ROOT/package" rscrypto-1.2.3 + +jq -n --arg commit "$commit" '{ + schema_version: 1, + kind: "rscrypto.ct.release-evidence", + crate: "rscrypto", + crate_version: "1.2.3", + git_commit: $commit, + evidence_git_commit: $commit, + evidence_mode: "exact_commit" +}' > "$TMP_ROOT/ct/CT-EVIDENCE-BUNDLE.json" +tar -czf "$artifacts/rscrypto-1.2.3-ct-evidence.tar.gz" -C "$TMP_ROOT/ct" CT-EVIDENCE-BUNDLE.json + +jq -n --arg commit "$commit" '{ + schema_version: 3, + kind: "rscrypto.repository-controls", + release_commit: $commit +}' > "$artifacts/rscrypto-1.2.3-repository-controls.json" + +write_manifest() { + "$MANIFEST_WRITER" \ + --root "$fixture" \ + --version 1.2.3 \ + --tag v1.2.3 \ + --commit "$commit" \ + --source "${SOURCE_PATH:-$artifacts/rscrypto-1.2.3-source.tar.gz}" \ + --crate "$artifacts/rscrypto-1.2.3.crate" \ + --ct-evidence "${CT_PATH:-$artifacts/rscrypto-1.2.3-ct-evidence.tar.gz}" \ + --repository-controls "${CONTROLS_PATH:-$artifacts/rscrypto-1.2.3-repository-controls.json}" \ + --evidence-commit "$commit" \ + --evidence-mode exact_commit \ + --output "${MANIFEST_PATH:-$artifacts/rscrypto-1.2.3-release-manifest.json}" +} + +github_output="$TMP_ROOT/github-output" +GITHUB_OUTPUT="$github_output" write_manifest >/dev/null +manifest="$artifacts/rscrypto-1.2.3-release-manifest.json" +jq -e --arg commit "$commit" ' + .schema_version == 1 + and .kind == "rscrypto.release-manifest" + and .crate_version == "1.2.3" + and .release.tag == "v1.2.3" + and (.release.tag_object | test("^[0-9a-f]{40}$")) + and .release.git_commit == $commit + and (.release.git_tree | test("^[0-9a-f]{40}$")) + and .toolchain.channel == "nightly-2026-04-27" + and (.toolchain.manifest.sha256 | test("^[0-9a-f]{64}$")) + and .evidence.git_commit == $commit + and .evidence.mode == "exact_commit" + and .artifacts.source_archive.name == "rscrypto-1.2.3-source.tar.gz" + and .artifacts.source_archive.prefix == "rscrypto-1.2.3/" + and .artifacts.crate_package.name == "rscrypto-1.2.3.crate" +' "$manifest" >/dev/null +grep -Fxq "manifest_path=$manifest" "$github_output" +grep -Fxq "manifest_name=$(basename "$manifest")" "$github_output" +grep -Eq '^manifest_sha256=[0-9a-f]{64}$' "$github_output" + +tampered_source="$TMP_ROOT/rscrypto-1.2.3-source.tar.gz" +cp "$artifacts/rscrypto-1.2.3-source.tar.gz" "$tampered_source" +printf 'tampered' >> "$tampered_source" +if SOURCE_PATH="$tampered_source" MANIFEST_PATH="$TMP_ROOT/tampered-source.json" write_manifest >/dev/null 2>&1; then + echo "release manifest accepted a source archive not reproduced from the release commit" >&2 + exit 1 +fi + +bad_controls="$TMP_ROOT/rscrypto-1.2.3-repository-controls.json" +jq '.release_commit = "0000000000000000000000000000000000000000"' \ + "$artifacts/rscrypto-1.2.3-repository-controls.json" > "$bad_controls" +if CONTROLS_PATH="$bad_controls" MANIFEST_PATH="$TMP_ROOT/bad-controls.json" write_manifest >/dev/null 2>&1; then + echo "release manifest accepted repository controls for another commit" >&2 + exit 1 +fi + +bad_ct_dir="$TMP_ROOT/bad-ct" +mkdir -p "$bad_ct_dir" +jq '.git_commit = "0000000000000000000000000000000000000000"' \ + "$TMP_ROOT/ct/CT-EVIDENCE-BUNDLE.json" > "$bad_ct_dir/CT-EVIDENCE-BUNDLE.json" +bad_ct="$TMP_ROOT/rscrypto-1.2.3-ct-evidence.tar.gz" +tar -czf "$bad_ct" -C "$bad_ct_dir" CT-EVIDENCE-BUNDLE.json +if CT_PATH="$bad_ct" MANIFEST_PATH="$TMP_ROOT/bad-ct.json" write_manifest >/dev/null 2>&1; then + echo "release manifest accepted CT evidence for another commit" >&2 + exit 1 +fi + +git -C "$fixture" commit --allow-empty -qm "move release" +moved_commit=$(git -C "$fixture" rev-parse HEAD) +git -C "$fixture" tag -fa v1.2.3 -m "moved release" >/dev/null +if "$SOURCE_PACKAGER" \ + --root "$fixture" \ + --version 1.2.3 \ + --tag v1.2.3 \ + --commit "$commit" \ + --out "$TMP_ROOT/moved" >/dev/null 2>&1; then + echo "source packager accepted moved tag $moved_commit for release commit $commit" >&2 + exit 1 +fi + +echo "Release identity regression tests passed" diff --git a/scripts/ci/release-preflight.sh b/scripts/ci/release-preflight.sh index 55fae743..83c637ef 100755 --- a/scripts/ci/release-preflight.sh +++ b/scripts/ci/release-preflight.sh @@ -125,3 +125,8 @@ scripts/ci/release-package-guard.sh \ --crate "$crate" \ --expected-version "$tag_version" \ --expected-git-sha "$tag_commit" + +scripts/ci/package-release-source.sh \ + --version "$tag_version" \ + --tag "$tag" \ + --commit "$tag_commit" diff --git a/scripts/ci/repository-controls-evidence-test.sh b/scripts/ci/repository-controls-evidence-test.sh index 40fd1049..fbb91a30 100755 --- a/scripts/ci/repository-controls-evidence-test.sh +++ b/scripts/ci/repository-controls-evidence-test.sh @@ -17,8 +17,21 @@ case "$endpoint" in repos/loadingalias/rscrypto) echo '{"id":1115910108,"full_name":"loadingalias/rscrypto","visibility":"public","default_branch":"main"}' ;; + repos/loadingalias/rscrypto/immutable-releases) + if [[ ${FAKE_GH_MODE:-success} == "immutability-unavailable" ]]; then + exit 1 + elif [[ ${FAKE_GH_MODE:-success} == "immutability-disabled" ]]; then + echo '{"enabled":false,"enforced_by_owner":false}' + else + echo '{"enabled":true,"enforced_by_owner":false}' + fi + ;; 'repos/loadingalias/rscrypto/rulesets?includes_parents=true&per_page=100') - echo '[{"id":19077982,"name":"protect-main","target":"branch","source_type":"Repository","source":"loadingalias/rscrypto","enforcement":"active"}]' + if [[ ${FAKE_GH_MODE:-success} == "missing-tag" ]]; then + echo '[{"id":19077982,"name":"protect-main","target":"branch","source_type":"Repository","source":"loadingalias/rscrypto","enforcement":"active"}]' + else + echo '[{"id":19077982,"name":"protect-main","target":"branch","source_type":"Repository","source":"loadingalias/rscrypto","enforcement":"active"},{"id":19077983,"name":"protect-release-tags","target":"tag","source_type":"Repository","source":"loadingalias/rscrypto","enforcement":"active"}]' + fi ;; repos/loadingalias/rscrypto/rulesets/19077982) jq --arg mode "${FAKE_GH_MODE:-success}" ' @@ -48,6 +61,32 @@ case "$endpoint" in end ' "$EXPECTED_POLICY" ;; + repos/loadingalias/rscrypto/rulesets/19077983) + jq --arg mode "${FAKE_GH_MODE:-success}" ' + . + { + id: 19077983, + source_type: "Repository", + source: "loadingalias/rscrypto", + current_user_can_bypass: "never", + created_at: "2026-07-17T12:00:00-04:00", + updated_at: "2026-07-17T12:00:00-04:00" + } + | if $mode == "tag-bypass" then + .bypass_actors = [{actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always"}] + | .current_user_can_bypass = "always" + elif $mode == "inactive-tag" then + .enforcement = "disabled" + elif $mode == "mutable-tag" then + .rules |= map(select(.type != "update")) + elif $mode == "redacted" then + del(.bypass_actors, .current_user_can_bypass) + elif $mode == "redacted-self" then + del(.bypass_actors) + else + . + end + ' "$EXPECTED_TAG_POLICY" + ;; 'repos/loadingalias/rscrypto/rules/branches/main?per_page=100') jq --arg mode "${FAKE_GH_MODE:-success}" ' [.rules[] | . + { @@ -71,6 +110,7 @@ chmod +x "$TMP_ROOT/bin/gh" export PATH="$TMP_ROOT/bin:$PATH" export EXPECTED_POLICY="$REPO_ROOT/.github/rulesets/protect-main.json" +export EXPECTED_TAG_POLICY="$REPO_ROOT/.github/rulesets/protect-release-tags.json" export EXPECTED_SHA EXPECTED_SHA=$(git -C "$REPO_ROOT" rev-parse HEAD) @@ -83,20 +123,25 @@ GITHUB_OUTPUT="$github_output" "$CHECKER" \ --output "$output" >/dev/null jq -e --arg commit "$EXPECTED_SHA" ' - .schema_version == 1 + .schema_version == 3 and .kind == "rscrypto.repository-controls" and .release_commit == $commit and .repository.full_name == "loadingalias/rscrypto" and .repository.default_branch == "main" and .repository.default_branch_sha == $commit and .live.ruleset.current_user_can_bypass == "never" + and .live.release_tag_ruleset.current_user_can_bypass == "never" + and .live.release_immutability.enabled == true + and .validation.release_immutability.status == "verified_enabled" + and ([.live.release_tag_ruleset.rules[] | select(.type == "update")] | length == 1) and ([.live.effective_rules[] | select(.type == "required_status_checks")] | length == 1) ' "$output" >/dev/null grep -Fxq "evidence_name=$(basename "$output")" "$github_output" grep -Fxq "evidence_path=$output" "$github_output" grep -Eq '^evidence_sha256=[0-9a-f]{64}$' "$github_output" -for mode in bypass inactive missing-check wrong-app wrong-effective redacted; do +for mode in bypass inactive missing-check wrong-app wrong-effective missing-tag tag-bypass inactive-tag mutable-tag \ + immutability-disabled immutability-unavailable redacted; do if FAKE_GH_MODE=$mode "$CHECKER" \ --root "$REPO_ROOT" \ --repo loadingalias/rscrypto \ @@ -114,7 +159,10 @@ FAKE_GH_MODE=redacted "$CHECKER" \ --commit "$EXPECTED_SHA" \ --output "$redacted_output" \ --allow-redacted-bypass >/dev/null -jq -e '.validation.bypass_actors.status == "redacted_by_github_api"' "$redacted_output" >/dev/null +jq -e ' + .validation.bypass_actors.status == "redacted_by_github_api" + and .validation.release_tag_bypass_actors.status == "redacted_by_github_api" +' "$redacted_output" >/dev/null FAKE_GH_MODE=redacted-self "$CHECKER" \ --root "$REPO_ROOT" \ @@ -133,4 +181,16 @@ if FAKE_GH_MODE=bypass "$CHECKER" \ exit 1 fi +immutability_unavailable_output="$TMP_ROOT/immutability-unavailable.json" +FAKE_GH_MODE=immutability-unavailable "$CHECKER" \ + --root "$REPO_ROOT" \ + --repo loadingalias/rscrypto \ + --commit "$EXPECTED_SHA" \ + --output "$immutability_unavailable_output" \ + --allow-redacted-bypass >/dev/null +jq -e ' + .validation.release_immutability.status == "unavailable_to_workflow_token" + and .live.release_immutability == null +' "$immutability_unavailable_output" >/dev/null + echo "Repository controls evidence regression tests passed" diff --git a/scripts/ci/repository-controls-evidence.sh b/scripts/ci/repository-controls-evidence.sh index 8d202d20..1cd0c07d 100755 --- a/scripts/ci/repository-controls-evidence.sh +++ b/scripts/ci/repository-controls-evidence.sh @@ -47,17 +47,35 @@ git -C "$root" cat-file -e "$commit^{commit}" 2>/dev/null || { } policy_path="$root/.github/rulesets/protect-main.json" +tag_policy_path="$root/.github/rulesets/protect-release-tags.json" +immutability_policy_path="$root/.github/repository-settings/release-immutability.json" [[ -f "$policy_path" ]] || { echo "repository controls error: missing policy $policy_path" >&2 exit 1 } +[[ -f "$tag_policy_path" ]] || { + echo "repository controls error: missing policy $tag_policy_path" >&2 + exit 1 +} +[[ -f "$immutability_policy_path" ]] || { + echo "repository controls error: missing policy $immutability_policy_path" >&2 + exit 1 +} jq -e 'type == "object"' "$policy_path" >/dev/null || { echo "repository controls error: policy must be a JSON object" >&2 exit 1 } +jq -e 'type == "object"' "$tag_policy_path" >/dev/null || { + echo "repository controls error: policy must be a JSON object" >&2 + exit 1 +} +jq -e '.enabled == true and (keys == ["enabled"])' "$immutability_policy_path" >/dev/null || { + echo "repository controls error: release immutability policy must require enabled=true" >&2 + exit 1 +} api() { - gh api -H "X-GitHub-Api-Version: 2022-11-28" "$1" + gh api -H "X-GitHub-Api-Version: 2026-03-10" "$1" } canonical_filter=' @@ -75,15 +93,35 @@ full_policy_filter="{name, target, enforcement, conditions, bypass_actors, rules public_policy_filter="{name, target, enforcement, conditions, rules} | $canonical_filter" repository=$(api "repos/$repo") +immutability_status="verified_enabled" +if immutability=$(api "repos/$repo/immutable-releases" 2>/dev/null); then + jq -e '.enabled == true' <<<"$immutability" >/dev/null || { + echo "repository controls error: immutable releases are not enabled" >&2 + exit 1 + } +else + if [[ "$allow_redacted_bypass" != true ]]; then + echo "repository controls error: immutable release settings are unavailable to this token" >&2 + exit 1 + fi + immutability=null + immutability_status="unavailable_to_workflow_token" +fi default_branch=$(jq -er '.default_branch | select(type == "string" and length > 0)' <<<"$repository") ruleset_name=$(jq -er '.name | select(type == "string" and length > 0)' "$policy_path") +tag_ruleset_name=$(jq -er '.name | select(type == "string" and length > 0)' "$tag_policy_path") rulesets=$(api "repos/$repo/rulesets?includes_parents=true&per_page=100") ruleset_id=$(jq -er --arg name "$ruleset_name" --arg source "$repo" ' [.[] | select(.name == $name and .target == "branch" and .source_type == "Repository" and .source == $source)] | if length == 1 then .[0].id else error("expected exactly one repository branch ruleset named " + $name) end ' <<<"$rulesets") +tag_ruleset_id=$(jq -er --arg name "$tag_ruleset_name" --arg source "$repo" ' + [.[] | select(.name == $name and .target == "tag" and .source_type == "Repository" and .source == $source)] + | if length == 1 then .[0].id else error("expected exactly one repository tag ruleset named " + $name) end +' <<<"$rulesets") ruleset=$(api "repos/$repo/rulesets/$ruleset_id") +tag_ruleset=$(api "repos/$repo/rulesets/$tag_ruleset_id") branch_path=$(jq -rn --arg branch "$default_branch" '$branch | @uri') effective_rules=$(api "repos/$repo/rules/branches/$branch_path?per_page=100") default_branch_commit=$(api "repos/$repo/commits/$branch_path") @@ -129,6 +167,46 @@ jq -e --arg repo "$repo" --arg bypass_evidence "$bypass_evidence" ' exit 1 } +tag_bypass_evidence="verified_empty" +tag_policy_filter=$full_policy_filter +if ! jq -e 'has("bypass_actors") and (.bypass_actors | type == "array")' <<<"$tag_ruleset" >/dev/null; then + if [[ "$allow_redacted_bypass" != true ]]; then + echo "repository controls error: GitHub redacted release-tag bypass actors; rerun with repository-rules write access" >&2 + exit 1 + fi + jq -e ' + (.bypass_actors // null) == null + and ((.current_user_can_bypass // null) == null or .current_user_can_bypass == "never") + ' <<<"$tag_ruleset" >/dev/null || { + echo "repository controls error: partial release-tag bypass data is not acceptable" >&2 + exit 1 + } + tag_bypass_evidence="redacted_by_github_api" + tag_policy_filter=$public_policy_filter +fi + +if ! diff -u \ + <(jq -S "$tag_policy_filter" "$tag_policy_path") \ + <(jq -S "$tag_policy_filter" <<<"$tag_ruleset"); then + echo "repository controls error: live ruleset differs from $tag_policy_path" >&2 + exit 1 +fi + +jq -e --arg repo "$repo" --arg bypass_evidence "$tag_bypass_evidence" ' + .source_type == "Repository" + and .source == $repo + and ( + if $bypass_evidence == "verified_empty" then + .bypass_actors == [] and .current_user_can_bypass == "never" + else + true + end + ) +' <<<"$tag_ruleset" >/dev/null || { + echo "repository controls error: the live release-tag ruleset is not repository-owned or permits this actor to bypass it" >&2 + exit 1 +} + if ! diff -u \ <(jq -S ".rules | $canonical_filter" "$policy_path") \ <(jq -S --argjson id "$ruleset_id" \ @@ -140,6 +218,8 @@ fi captured_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ') policy_sha256=$(sha256sum "$policy_path" | awk '{print $1}') +tag_policy_sha256=$(sha256sum "$tag_policy_path" | awk '{print $1}') +immutability_policy_sha256=$(sha256sum "$immutability_policy_path" | awk '{print $1}') output_dir=$(dirname "$output") mkdir -p "$output_dir" output_tmp="${output}.tmp.$$" @@ -151,15 +231,25 @@ jq -nS \ --arg default_branch "$default_branch" \ --arg default_branch_sha "$default_branch_sha" \ --arg bypass_evidence "$bypass_evidence" \ + --arg tag_bypass_evidence "$tag_bypass_evidence" \ + --arg immutability_status "$immutability_status" \ --arg policy_path ".github/rulesets/protect-main.json" \ --arg policy_sha256 "$policy_sha256" \ + --arg tag_policy_path ".github/rulesets/protect-release-tags.json" \ + --arg tag_policy_sha256 "$tag_policy_sha256" \ + --arg immutability_policy_path ".github/repository-settings/release-immutability.json" \ + --arg immutability_policy_sha256 "$immutability_policy_sha256" \ --arg repo "$repo" \ --argjson repository "$repository" \ --argjson policy "$(jq -S "$canonical_filter" "$policy_path")" \ + --argjson tag_policy "$(jq -S "$canonical_filter" "$tag_policy_path")" \ + --argjson immutability_policy "$(jq -S "$canonical_filter" "$immutability_policy_path")" \ + --argjson immutability "$immutability" \ --argjson ruleset "$ruleset" \ + --argjson tag_ruleset "$tag_ruleset" \ --argjson effective_rules "$effective_rules" ' { - schema_version: 1, + schema_version: 3, kind: "rscrypto.repository-controls", captured_at: $captured_at, release_commit: $commit, @@ -175,15 +265,36 @@ jq -nS \ sha256: $policy_sha256, expected: $policy }, + release_tag_policy: { + path: $tag_policy_path, + sha256: $tag_policy_sha256, + expected: $tag_policy + }, + release_immutability_policy: { + path: $immutability_policy_path, + sha256: $immutability_policy_sha256, + expected: $immutability_policy + }, validation: { bypass_actors: { expected: [], status: $bypass_evidence, current_user_can_bypass: ($ruleset.current_user_can_bypass // null) + }, + release_tag_bypass_actors: { + expected: [], + status: $tag_bypass_evidence, + current_user_can_bypass: ($tag_ruleset.current_user_can_bypass // null) + }, + release_immutability: { + expected: {enabled: true}, + status: $immutability_status } }, live: { ruleset: $ruleset, + release_tag_ruleset: $tag_ruleset, + release_immutability: $immutability, effective_rules: $effective_rules } } diff --git a/scripts/ci/write-release-manifest.sh b/scripts/ci/write-release-manifest.sh new file mode 100755 index 00000000..9b15e3a9 --- /dev/null +++ b/scripts/ci/write-release-manifest.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: scripts/ci/write-release-manifest.sh [options] + +Required: + --version VERSION + --tag TAG + --commit SHA + --source PATH + --crate PATH + --ct-evidence PATH + --repository-controls PATH + --evidence-commit SHA + --evidence-mode exact_commit|release_only_delta + --output PATH + +Optional: + --root PATH Repository root (default: current repository) +EOF +} + +version="" +tag="" +commit="" +source_archive="" +crate_package="" +ct_evidence="" +repository_controls="" +evidence_commit="" +evidence_mode="" +output="" +root="$(git rev-parse --show-toplevel)" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) version=${2:?}; shift 2 ;; + --tag) tag=${2:?}; shift 2 ;; + --commit) commit=${2:?}; shift 2 ;; + --source) source_archive=${2:?}; shift 2 ;; + --crate) crate_package=${2:?}; shift 2 ;; + --ct-evidence) ct_evidence=${2:?}; shift 2 ;; + --repository-controls) repository_controls=${2:?}; shift 2 ;; + --evidence-commit) evidence_commit=${2:?}; shift 2 ;; + --evidence-mode) evidence_mode=${2:?}; shift 2 ;; + --output) output=${2:?}; shift 2 ;; + --root) root=${2:?}; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +fail() { + echo "release manifest error: $*" >&2 + exit 1 +} + +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] || fail "invalid version: $version" +[[ "$tag" == "v$version" ]] || fail "tag $tag does not match version $version" +[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "release commit must be a full lowercase Git commit" +[[ "$evidence_commit" =~ ^[0-9a-f]{40}$ ]] || fail "evidence commit must be a full lowercase Git commit" +[[ "$evidence_mode" == "exact_commit" || "$evidence_mode" == "release_only_delta" ]] \ + || fail "invalid evidence mode: $evidence_mode" +[[ -n "$output" ]] || fail "--output is required" + +for path in "$source_archive" "$crate_package" "$ct_evidence" "$repository_controls"; do + [[ -f "$path" ]] || fail "artifact is missing: $path" +done + +git -C "$root" rev-parse -q --verify "$tag^{tag}" >/dev/null || fail "release tag is not annotated: $tag" +tag_object=$(git -C "$root" rev-parse "$tag^{tag}") +tag_commit=$(git -C "$root" rev-parse "$tag^{commit}") +[[ "$tag_commit" == "$commit" ]] || fail "tag $tag resolves to $tag_commit, not $commit" +git_tree=$(git -C "$root" rev-parse "$commit^{tree}") + +expected_source_name="rscrypto-${version}-source.tar.gz" +[[ $(basename "$source_archive") == "$expected_source_name" ]] \ + || fail "unexpected source archive name: $(basename "$source_archive")" +reproduced_dir=$(mktemp -d) +committed_toolchain=$(mktemp) +committed_lock=$(mktemp) +committed_workflow=$(mktemp) +trap 'rm -rf "$reproduced_dir"; rm -f "$committed_toolchain" "$committed_lock" "$committed_workflow"' EXIT +"$(dirname "$0")/package-release-source.sh" \ + --root "$root" \ + --version "$version" \ + --tag "$tag" \ + --commit "$commit" \ + --out "$reproduced_dir" >/dev/null +cmp -s "$source_archive" "$reproduced_dir/$expected_source_name" \ + || fail "source archive is not the deterministic archive for $commit" + +expected_crate_name="rscrypto-${version}.crate" +[[ $(basename "$crate_package") == "$expected_crate_name" ]] \ + || fail "unexpected crate package name: $(basename "$crate_package")" +vcs_json=$(tar -xOf "$crate_package" "rscrypto-${version}/.cargo_vcs_info.json" 2>/dev/null) \ + || fail "crate package lacks .cargo_vcs_info.json" +jq -e --arg commit "$commit" ' + .git.sha1 == $commit + and (.git.dirty // false) == false + and ((.path_in_vcs // "") == "") +' <<< "$vcs_json" >/dev/null || fail "crate package is not bound to release commit $commit" + +expected_ct_name="rscrypto-${version}-ct-evidence.tar.gz" +[[ $(basename "$ct_evidence") == "$expected_ct_name" ]] \ + || fail "unexpected CT evidence name: $(basename "$ct_evidence")" +ct_metadata=$(tar -xOf "$ct_evidence" CT-EVIDENCE-BUNDLE.json 2>/dev/null) \ + || fail "CT evidence lacks CT-EVIDENCE-BUNDLE.json" +jq -e \ + --arg version "$version" \ + --arg commit "$commit" \ + --arg evidence_commit "$evidence_commit" \ + --arg evidence_mode "$evidence_mode" ' + .schema_version == 1 + and .kind == "rscrypto.ct.release-evidence" + and .crate == "rscrypto" + and .crate_version == $version + and .git_commit == $commit + and .evidence_git_commit == $evidence_commit + and .evidence_mode == $evidence_mode +' <<< "$ct_metadata" >/dev/null || fail "CT evidence identity does not match the release" + +expected_controls_name="rscrypto-${version}-repository-controls.json" +[[ $(basename "$repository_controls") == "$expected_controls_name" ]] \ + || fail "unexpected repository-controls name: $(basename "$repository_controls")" +jq -e --arg commit "$commit" ' + .kind == "rscrypto.repository-controls" + and .release_commit == $commit +' "$repository_controls" >/dev/null || fail "repository controls are not bound to release commit $commit" + +git -C "$root" show "$commit:rust-toolchain.toml" > "$committed_toolchain" +git -C "$root" show "$commit:Cargo.lock" > "$committed_lock" +git -C "$root" show "$commit:.github/workflows/release.yaml" > "$committed_workflow" +toolchain_channel=$(awk -F'"' '/^channel/ {print $2}' "$committed_toolchain") +[[ -n "$toolchain_channel" ]] || fail "committed rust-toolchain.toml has no channel" +active_toolchain=$(cd "$root" && rustup show active-toolchain | awk '{print $1}') +[[ "$active_toolchain" == "$toolchain_channel"* ]] \ + || fail "active toolchain $active_toolchain does not match pinned channel $toolchain_channel" +rustc_version=$(cd "$root" && rustc -Vv) +cargo_version=$(cd "$root" && cargo -V) + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +source_sha256=$(sha256_file "$source_archive") +crate_sha256=$(sha256_file "$crate_package") +ct_sha256=$(sha256_file "$ct_evidence") +controls_sha256=$(sha256_file "$repository_controls") +toolchain_sha256=$(sha256_file "$committed_toolchain") +lock_sha256=$(sha256_file "$committed_lock") +workflow_sha256=$(sha256_file "$committed_workflow") + +mkdir -p "$(dirname "$output")" +output_tmp="${output}.tmp.$$" +trap 'rm -rf "$reproduced_dir"; rm -f "$committed_toolchain" "$committed_lock" "$committed_workflow" "$output_tmp"' EXIT +jq -nS \ + --arg version "$version" \ + --arg tag "$tag" \ + --arg tag_object "$tag_object" \ + --arg commit "$commit" \ + --arg tree "$git_tree" \ + --arg evidence_commit "$evidence_commit" \ + --arg evidence_mode "$evidence_mode" \ + --arg toolchain_channel "$toolchain_channel" \ + --arg active_toolchain "$active_toolchain" \ + --arg rustc "$rustc_version" \ + --arg cargo "$cargo_version" \ + --arg toolchain_sha256 "$toolchain_sha256" \ + --arg lock_sha256 "$lock_sha256" \ + --arg workflow_sha256 "$workflow_sha256" \ + --arg source_name "$(basename "$source_archive")" \ + --arg source_sha256 "$source_sha256" \ + --arg crate_name "$(basename "$crate_package")" \ + --arg crate_sha256 "$crate_sha256" \ + --arg ct_name "$(basename "$ct_evidence")" \ + --arg ct_sha256 "$ct_sha256" \ + --arg controls_name "$(basename "$repository_controls")" \ + --arg controls_sha256 "$controls_sha256" ' + { + schema_version: 1, + kind: "rscrypto.release-manifest", + crate: "rscrypto", + crate_version: $version, + release: { + tag: $tag, + tag_object: $tag_object, + git_commit: $commit, + git_tree: $tree + }, + toolchain: { + channel: $toolchain_channel, + active: $active_toolchain, + rustc: $rustc, + cargo: $cargo, + manifest: {path: "rust-toolchain.toml", sha256: $toolchain_sha256} + }, + inputs: { + cargo_lock: {path: "Cargo.lock", sha256: $lock_sha256}, + release_workflow: {path: ".github/workflows/release.yaml", sha256: $workflow_sha256} + }, + evidence: { + git_commit: $evidence_commit, + mode: $evidence_mode + }, + artifacts: { + source_archive: { + name: $source_name, + sha256: $source_sha256, + format: "git-archive+tar+gzip-n", + prefix: ("rscrypto-" + $version + "/") + }, + crate_package: {name: $crate_name, sha256: $crate_sha256}, + ct_evidence: {name: $ct_name, sha256: $ct_sha256}, + repository_controls: {name: $controls_name, sha256: $controls_sha256} + } + } +' > "$output_tmp" +mv "$output_tmp" "$output" +trap 'rm -rf "$reproduced_dir"; rm -f "$committed_toolchain" "$committed_lock" "$committed_workflow"' EXIT + +manifest_sha256=$(sha256_file "$output") +if [[ -n ${GITHUB_OUTPUT:-} ]]; then + { + echo "manifest_path=$output" + echo "manifest_name=$(basename "$output")" + echo "manifest_sha256=$manifest_sha256" + } >> "$GITHUB_OUTPUT" +fi + +echo "release manifest: $output" +echo "sha256: $manifest_sha256"