From b0664242d357e35646a67f83a7dbb7f3f59bf21d Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Fri, 29 May 2026 22:42:21 -0500 Subject: [PATCH 1/2] Audit release environment settings Signed-off-by: Nelson Spence --- .github/workflows/release.yml | 11 ++- Cargo.toml | 1 + README.md | 7 +- RELEASING.md | 18 ++++- tests/release_environment_settings.sh | 90 ++++++++++++++++++++++ tests/release_signed_release_invariants.sh | 6 +- 6 files changed, 120 insertions(+), 13 deletions(-) create mode 100755 tests/release_environment_settings.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7822487..c37aca2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,10 +37,12 @@ # # Provenance / attestation, soup to nuts (all genuine, nothing faked): # * SLSA generator -> `*.intoto.jsonl` on the Release (OpenSSF Scorecard -# Signed-Releases provenance probe -> 10/10; SLSA Build L3). +# Signed-Releases provenance probe; older unsigned releases may keep that +# score below 10 temporarily; SLSA Build L3). # * actions/attest-build-provenance -> GitHub attestation store + a # `*.sigstore.json` bundle on the Release (`gh attestation verify`; also the -# Scorecard signing probe -> 8, a backup if the .intoto.jsonl ever regresses). +# Scorecard signing probe sees this asset as a backup if the .intoto.jsonl +# ever regresses). # * gh-action-pypi-publish -> PEP 740 attestations on PyPI (Integrity API). # * post-publish PyPI JSON hash check -> every served wheel/sdist digest # matches the staged dist files. @@ -218,8 +220,9 @@ jobs: with: toolchain: stable - name: Package the crate - # Emits target/package/ordvec-.crate — the same content - # `cargo publish` uploads, so the provenance covers the published artifact. + # Emits the SLSA-attested .crate artifact. `publish-crate` later + # compares both a local repackage and the crates.io-served artifact to + # this file. run: cargo package -p ordvec --locked - name: Generate CycloneDX SBOM for the crate run: | diff --git a/Cargo.toml b/Cargo.toml index 049cef7..68fbbd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ exclude = [ "ordvec-manifest/", "ordvec-python/", "tests/__pycache__/", + "tests/release_environment_settings.sh", "tests/release_publish_invariants.py", "tests/release_publish_invariants.sh", "tests/release_signed_release_invariants.sh", diff --git a/README.md b/README.md index 94400f1..02c92b8 100644 --- a/README.md +++ b/README.md @@ -263,13 +263,14 @@ Collaboration we're actively seeking: - **Independent reproduction** — re-running the benchmark on other hardware and reporting the numbers. -If that's your area, see [GOVERNANCE.md](GOVERNANCE.md) and open an issue or a -discussion. +If that's your area, see +[GOVERNANCE.md](https://github.com/Fieldnote-Echo/ordvec/blob/main/GOVERNANCE.md) +and open an issue or a discussion. ## Contributing Contributions to the code, the docs, and the paper are all welcome — see -[CONTRIBUTING.md](CONTRIBUTING.md). +[CONTRIBUTING.md](https://github.com/Fieldnote-Echo/ordvec/blob/main/CONTRIBUTING.md). ## Minimum supported Rust version diff --git a/RELEASING.md b/RELEASING.md index 54e412a..a84552f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -126,8 +126,18 @@ filename. Until either is updated, the corresponding gated publish fails strategy. An interior commit that exists in history only from a PR branch has no push-to-main run (its CI ran as a `pull_request` on the branch) and so is not releasable. -4. Get the maintainer's explicit go to publish. -5. Push the version tag from `main` (signed): +4. Run the manual release-settings audit before creating the tag: + + ```sh + bash tests/release_environment_settings.sh + ``` + + This verifies the GitHub Environments still require the expected reviewer + and accept only the stable release tag pattern. Separately verify the + registry Trusted Publisher records by hand: crates.io must point to + `release.yml` / `crates-io`, and PyPI must point to `release.yml` / `pypi`. +5. Get the maintainer's explicit go to publish. +6. Push the version tag from `main` (signed): ```sh git tag -s vX.Y.Z -m "vX.Y.Z" @@ -139,7 +149,7 @@ filename. Until either is updated, the corresponding gated publish fails generates the SLSA `*.intoto.jsonl`; and stages every artifact, the attestation bundle, and the provenance on the GitHub Release — **as a DRAFT**. It then pauses at the two registry environment gates. -6. **Approve the two publish environments** when they pause in the Actions UI +7. **Approve the two publish environments** when they pause in the Actions UI (one for `crates-io`, one for `pypi`). The required-reviewer approval is what authorises the registry push. - `publish-crate` first sha256-compares its repackaged `.crate` to the @@ -152,7 +162,7 @@ filename. Until either is updated, the corresponding gated publish fails - `publish-pypi` also queries PyPI after upload and compares every served wheel/sdist SHA-256 digest against the staged `dist/` files before the GitHub Release can un-draft. -7. Verify each published artifact and its provenance: +8. Verify each published artifact and its provenance: - crates.io / docs.rs; - PyPI (confirm the post-publish hash-verification log, optionally `pip download ordvec==X.Y.Z` and inspect, plus check the PEP 740 attestation diff --git a/tests/release_environment_settings.sh b/tests/release_environment_settings.sh new file mode 100755 index 0000000..bcfedec --- /dev/null +++ b/tests/release_environment_settings.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Manual pre-tag audit for GitHub Environment release gates. +# +# This is intentionally not a normal CI check: it requires an authenticated +# gh token that can read repository environment settings. +set -euo pipefail + +REPO="${REPO:-Fieldnote-Echo/ordvec}" +EXPECTED_REVIEWER="${EXPECTED_REVIEWER:-Fieldnote-Echo}" +EXPECTED_POLICY="${EXPECTED_POLICY:-v[0-9]*.[0-9]*.[0-9]*}" +ENVIRONMENTS=(crates-io pypi) + +fail() { + echo "::error::release environment settings audit failed: $*" + exit 1 +} + +api_jq() { + local path="$1" + local filter="$2" + local output + + if ! output="$(gh api "$path" --jq "$filter" 2>&1)"; then + fail "cannot read ${path}; authenticate with a token that can read ${REPO} repository environment settings. gh api: ${output}" + fi + + printf '%s\n' "$output" +} + +if ! gh auth status; then + fail "gh auth status failed; run gh auth login with an account/token that can read ${REPO} repository environment settings" +fi + +api_jq "repos/${REPO}/environments?per_page=100" '.total_count' >/dev/null + +check_environment() { + local env="$1" + local env_path="repos/${REPO}/environments/${env}" + local policies_path="${env_path}/deployment-branch-policies?per_page=100" + local env_name required_rule_count reviewer_count reviewer_summary + local custom_branch_policies protected_branches + local policy_total policy_summary policy_type policy_name + + echo "Auditing ${REPO} environment ${env}..." + + env_name="$(api_jq "$env_path" '.name // ""')" + [ "$env_name" = "$env" ] \ + || fail "${env}: environment not found" + + required_rule_count="$(api_jq "$env_path" '[.protection_rules[]? | select(.type == "required_reviewers")] | length')" + [ "$required_rule_count" = "1" ] \ + || fail "${env}: expected exactly one required_reviewers protection rule; found ${required_rule_count}" + + reviewer_count="$(api_jq "$env_path" '[.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]?] | length')" + reviewer_summary="$(api_jq "$env_path" '[.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | "\(.type):\(.reviewer.login // .reviewer.slug // .reviewer.name // "unknown")"] | join(", ")')" + [ "$reviewer_count" = "1" ] \ + || fail "${env}: expected exactly one required reviewer User:${EXPECTED_REVIEWER}; found ${reviewer_count} (${reviewer_summary:-none})" + [ "$reviewer_summary" = "User:${EXPECTED_REVIEWER}" ] \ + || fail "${env}: expected required reviewer User:${EXPECTED_REVIEWER}; found ${reviewer_summary:-none}" + + custom_branch_policies="$(api_jq "$env_path" '.deployment_branch_policy.custom_branch_policies')" + [ "$custom_branch_policies" = "true" ] \ + || fail "${env}: expected deployment_branch_policy.custom_branch_policies == true; found ${custom_branch_policies}" + + protected_branches="$(api_jq "$env_path" '.deployment_branch_policy.protected_branches')" + [ "$protected_branches" = "false" ] \ + || fail "${env}: expected deployment_branch_policy.protected_branches == false; found ${protected_branches}" + + policy_total="$(api_jq "$policies_path" '.total_count')" + policy_summary="$(api_jq "$policies_path" '[.branch_policies[]? | "\(.type):\(.name)"] | join(", ")')" + [ "$policy_total" = "1" ] \ + || fail "${env}: expected exactly one deployment branch/tag policy tag:${EXPECTED_POLICY}; found ${policy_total} (${policy_summary:-none})" + + policy_type="$(api_jq "$policies_path" '.branch_policies[0].type // ""')" + [ "$policy_type" = "tag" ] \ + || fail "${env}: expected deployment policy type tag; found ${policy_type:-none}" + + policy_name="$(api_jq "$policies_path" '.branch_policies[0].name // ""')" + [ "$policy_name" = "$EXPECTED_POLICY" ] \ + || fail "${env}: expected deployment policy name ${EXPECTED_POLICY}; found ${policy_name:-none}" + + echo "OK: ${env} requires User:${EXPECTED_REVIEWER} and only tag:${EXPECTED_POLICY}." +} + +for env in "${ENVIRONMENTS[@]}"; do + check_environment "$env" +done + +echo "OK: release environment settings match the pre-tag policy." diff --git a/tests/release_signed_release_invariants.sh b/tests/release_signed_release_invariants.sh index d2824fc..eec5a30 100755 --- a/tests/release_signed_release_invariants.sh +++ b/tests/release_signed_release_invariants.sh @@ -2,8 +2,10 @@ # # Signed-release / provenance invariants — pinned in CI. # -# release.yml's signed-release graph is what gets us OpenSSF Scorecard -# Signed-Releases = 10 and keeps the build-attest-publish chain honest: +# release.yml's signed-release graph attaches the .intoto.jsonl and Sigstore +# assets that OpenSSF Scorecard detects for Signed-Releases, while older +# unsigned releases may keep the score below 10 temporarily. The same graph +# keeps the build-attest-publish chain honest: # # build-{crate,wheels,sdist} (artifacts) # | From f7a77f0b1ecc34563175d89f28c4cf490798744f Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Fri, 29 May 2026 23:06:13 -0500 Subject: [PATCH 2/2] Harden release environment audit Signed-off-by: Nelson Spence --- tests/release_environment_settings.sh | 49 ++++++++++++++++++--------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/tests/release_environment_settings.sh b/tests/release_environment_settings.sh index bcfedec..dc62748 100755 --- a/tests/release_environment_settings.sh +++ b/tests/release_environment_settings.sh @@ -19,64 +19,81 @@ fail() { api_jq() { local path="$1" local filter="$2" - local output + local err output stderr - if ! output="$(gh api "$path" --jq "$filter" 2>&1)"; then - fail "cannot read ${path}; authenticate with a token that can read ${REPO} repository environment settings. gh api: ${output}" + if ! err="$(mktemp)"; then + fail "could not create temporary file for gh api stderr" fi + if ! output="$(gh api "$path" --jq "$filter" 2>"$err")"; then + stderr="$(cat "$err")" + rm -f "$err" + fail "cannot read ${path}; authenticate with a token that can read ${REPO} repository environment settings. gh api: ${stderr}" + fi + rm -f "$err" + printf '%s\n' "$output" } -if ! gh auth status; then +command -v gh >/dev/null 2>&1 \ + || fail "gh CLI not found; install GitHub CLI (gh) and authenticate before running this audit" + +if ! gh auth status -h github.com; then fail "gh auth status failed; run gh auth login with an account/token that can read ${REPO} repository environment settings" fi -api_jq "repos/${REPO}/environments?per_page=100" '.total_count' >/dev/null - check_environment() { local env="$1" local env_path="repos/${REPO}/environments/${env}" local policies_path="${env_path}/deployment-branch-policies?per_page=100" + local env_data policies_data local env_name required_rule_count reviewer_count reviewer_summary local custom_branch_policies protected_branches local policy_total policy_summary policy_type policy_name echo "Auditing ${REPO} environment ${env}..." - env_name="$(api_jq "$env_path" '.name // ""')" + env_data="$(api_jq "$env_path" '[ + (.name // ""), + ([.protection_rules[]? | select(.type == "required_reviewers")] | length | tostring), + ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]?] | length | tostring), + ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | "\(.type):\(.reviewer.login // .reviewer.slug // .reviewer.name // "unknown")"] | join(", ")), + (.deployment_branch_policy.custom_branch_policies | tostring), + (.deployment_branch_policy.protected_branches | tostring) + ] | @tsv')" + IFS=$'\t' read -r env_name required_rule_count reviewer_count reviewer_summary custom_branch_policies protected_branches <<< "$env_data" + [ "$env_name" = "$env" ] \ || fail "${env}: environment not found" - required_rule_count="$(api_jq "$env_path" '[.protection_rules[]? | select(.type == "required_reviewers")] | length')" [ "$required_rule_count" = "1" ] \ || fail "${env}: expected exactly one required_reviewers protection rule; found ${required_rule_count}" - reviewer_count="$(api_jq "$env_path" '[.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]?] | length')" - reviewer_summary="$(api_jq "$env_path" '[.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | "\(.type):\(.reviewer.login // .reviewer.slug // .reviewer.name // "unknown")"] | join(", ")')" [ "$reviewer_count" = "1" ] \ || fail "${env}: expected exactly one required reviewer User:${EXPECTED_REVIEWER}; found ${reviewer_count} (${reviewer_summary:-none})" [ "$reviewer_summary" = "User:${EXPECTED_REVIEWER}" ] \ || fail "${env}: expected required reviewer User:${EXPECTED_REVIEWER}; found ${reviewer_summary:-none}" - custom_branch_policies="$(api_jq "$env_path" '.deployment_branch_policy.custom_branch_policies')" [ "$custom_branch_policies" = "true" ] \ || fail "${env}: expected deployment_branch_policy.custom_branch_policies == true; found ${custom_branch_policies}" - protected_branches="$(api_jq "$env_path" '.deployment_branch_policy.protected_branches')" [ "$protected_branches" = "false" ] \ || fail "${env}: expected deployment_branch_policy.protected_branches == false; found ${protected_branches}" - policy_total="$(api_jq "$policies_path" '.total_count')" - policy_summary="$(api_jq "$policies_path" '[.branch_policies[]? | "\(.type):\(.name)"] | join(", ")')" + policies_data="$(api_jq "$policies_path" '[ + (.total_count | tostring), + ([.branch_policies[]? | "\(.type):\(.name)"] | join(", ")), + (.branch_policies[0].type // ""), + (.branch_policies[0].name // "") + ] | @tsv')" + IFS=$'\t' read -r policy_total policy_summary policy_type policy_name <<< "$policies_data" + [ "$policy_total" = "1" ] \ || fail "${env}: expected exactly one deployment branch/tag policy tag:${EXPECTED_POLICY}; found ${policy_total} (${policy_summary:-none})" - policy_type="$(api_jq "$policies_path" '.branch_policies[0].type // ""')" [ "$policy_type" = "tag" ] \ || fail "${env}: expected deployment policy type tag; found ${policy_type:-none}" - policy_name="$(api_jq "$policies_path" '.branch_policies[0].name // ""')" [ "$policy_name" = "$EXPECTED_POLICY" ] \ || fail "${env}: expected deployment policy name ${EXPECTED_POLICY}; found ${policy_name:-none}"