From cf7fc45f0ba4b372058d6ce7c69c677741eace38 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Fri, 19 Jun 2026 17:28:23 -0500 Subject: [PATCH] update release governance gates --- .github/workflows/release.yml | 11 ++++--- CHANGELOG.md | 9 ++++++ CONTRIBUTING.md | 7 ++-- GOVERNANCE.md | 4 +++ RELEASING.md | 46 +++++++++++++++++---------- THREAT_MODEL.md | 40 +++++++++++------------ tests/release_environment_settings.sh | 32 +++++++++++++------ 7 files changed, 95 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 051d5745..bbe46149 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,8 @@ # -> owner = `Project-Navi`, repository = `ordvec`, workflow = # `release.yml` (env stays `pypi`). # * GitHub Environments `crates-io` AND `pypi`: -# - keep "Required reviewers" (the human publish gate); +# - keep Required reviewers `Fieldnote-Echo` and `toadkicker`, enable +# prevent self-review, and set the wait timer to 30 minutes; # - set "Deployment branches and tags" to **Selected branches and tags** # with a single TAG pattern: `v[0-9]*.[0-9]*.[0-9]*`. The old # workflow_dispatch setting (branch = `main` only) would now deadlock @@ -1099,7 +1100,7 @@ jobs: needs: [guard, release-assets-draft] if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest - environment: crates-io # MANUAL GATE — Required reviewer + environment: crates-io # MANUAL GATE — required reviewer, no self-review, 30 min wait permissions: contents: read id-token: write # Trusted Publishing (OIDC) @@ -1482,7 +1483,7 @@ jobs: needs: [guard, release-manifest-assets-draft, publish-crate] if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest - environment: crates-io # MANUAL GATE — Required reviewer + environment: crates-io # MANUAL GATE — required reviewer, no self-review, 30 min wait permissions: contents: read id-token: write # Trusted Publishing (OIDC) @@ -1682,7 +1683,7 @@ jobs: if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest environment: - name: pypi # MANUAL GATE — Required reviewer + name: pypi # MANUAL GATE — required reviewer, no self-review, 30 min wait url: https://pypi.org/p/ordvec-manifest permissions: contents: read @@ -1733,7 +1734,7 @@ jobs: if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest environment: - name: pypi # MANUAL GATE — Required reviewer + name: pypi # MANUAL GATE — required reviewer, no self-review, 30 min wait url: https://pypi.org/p/ordvec permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b070356..1d4e2ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security + +- Updated release governance to document and audit the two-approver + `crates-io` / `pypi` GitHub Environment gates: `Fieldnote-Echo` and + `toadkicker` are listed as required reviewers, self-review is blocked, and a + 30-minute wait timer applies before registry publish jobs can proceed. + ## 0.5.0 - 2026-06-19 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 329eb170..d7c7eaba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,9 +117,10 @@ Changelog and release notes are generated with `ordvec-manifest` crate is built and staged only after the core `ordvec` crate publishes, because Cargo cannot package a fresh manifest version until the matching core version exists on crates.io. The `crates.io` and `pypi` - publishes wait at GitHub Environments with **Required reviewers** (the - maintainer approves each in the Actions UI), and the GitHub Release un-drafts - only after all registry publishes succeed. Pre-release tags (e.g. + publishes wait at GitHub Environments with **Required reviewers** + (`@Fieldnote-Echo` and `@toadkicker`), self-review prevention, and a + 30-minute wait timer, and the GitHub Release un-drafts only after all + registry publishes succeed. Pre-release tags (e.g. `v0.3.0-rc.1`) do not trigger it. - **`CHANGELOG.md` is curated by hand** — it is not auto-committed, because `main` is branch-protected. Keep adding entries under `[Unreleased]`; at diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 3418fa66..46034c97 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -26,6 +26,10 @@ describes how it is run. direction-setting changes (scope, public API, dependencies) are discussed in an issue or pull request first; the project lead makes the final call, consistent with the [roadmap](ROADMAP.md). +- Registry publishes are gated by the `crates-io` and `pypi` GitHub + Environments. Both list `@Fieldnote-Echo` and `@toadkicker` as required + reviewers, block self-review, and enforce a 30-minute wait timer before a + publish job can mint registry OIDC credentials; see [RELEASING.md](RELEASING.md). - Decisions favour the project's stated scope — ordvec is a retrieval _primitive_ for edge / local AI retrieval, **not** a standalone vector database (see [ROADMAP.md](ROADMAP.md)). diff --git a/RELEASING.md b/RELEASING.md index 683ae875..6cac74e3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,11 +1,11 @@ # Releasing `ordvec` -> **Publish is held.** A real crates.io / PyPI publish happens only on -> the maintainer's explicit approval. CI never publishes — the unified release -> pipeline builds, attests, and attaches everything to the GitHub Release -> automatically on a tag push, then **waits at the `crates-io` and `pypi` -> environment gates** for a required-reviewer approval before any registry -> push. +> **Publish is held.** A real crates.io / PyPI publish happens only after +> an eligible release approver explicitly approves the protected deployment. +> CI never publishes on its own — the unified release pipeline builds, attests, +> and attaches everything to the GitHub Release automatically on a tag push, +> then **waits at the `crates-io` and `pypi` environment gates** for the +> required-reviewer approval and 30-minute wait timer before any registry push. `ordvec` (the Rust crate), `ordvec-manifest` (the lockstep manifest verifier crate), `ordvec` on PyPI (the PyO3 wheel built from `ordvec-python/`), and @@ -86,8 +86,15 @@ the GitHub Release. ### Environment protection (configured in repo settings, not in code) -- **Required reviewer** — each environment (`crates-io`, `pypi`) requires - maintainer (`Fieldnote-Echo`) approval before its publish job runs. +- **Required reviewers and self-review prevention** — each environment + (`crates-io`, `pypi`) lists `Fieldnote-Echo` and `toadkicker` as required + reviewers and has **prevent self-review** enabled. GitHub still requires one + approving reviewer per deployment, but the account that triggered the + deployment cannot approve it; a second listed release approver must clear the + publish gate. +- **Wait timer** — each environment has a **30-minute wait timer**. No registry + OIDC credential is minted until both the wait timer and the required-reviewer + approval have passed. - **Deployment branches and tags** — each environment's "Deployment branches and tags" policy is set to **Selected branches and tags** with a single **tag pattern**: **`v[0-9]*.[0-9]*.[0-9]*`** (matching the workflow's @@ -101,8 +108,9 @@ the GitHub Release. **tag ruleset** (Settings → Rules → Rulesets → New tag ruleset) can be added to restrict tag *creation* to refs on `main` as defence in depth. -> These two settings are the supply-chain backstop the workflow code cannot -> express on its own (THREAT-SUPPLY-001 in [THREAT_MODEL.md](THREAT_MODEL.md)). +> These environment settings are the supply-chain backstop the workflow code +> cannot express on its own (THREAT-SUPPLY-001 in +> [THREAT_MODEL.md](THREAT_MODEL.md)). ### Trusted-publisher configuration (one-time, in the registries) @@ -203,12 +211,13 @@ the OIDC exchange (no risk of a bad publish; just a failed run). 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 both - `ordvec` and `ordvec-manifest` to `release.yml` / `crates-io`, and PyPI must - point both `ordvec` and `ordvec-manifest` to `release.yml` / `pypi`. -6. Get the maintainer's explicit go to publish. + This verifies the GitHub Environments still require the expected reviewers, + prevent self-review, apply the 30-minute wait timer, and accept only the + stable release tag pattern. Separately verify the registry Trusted Publisher + records by hand: crates.io must point both `ordvec` and `ordvec-manifest` to + `release.yml` / `crates-io`, and PyPI must point both `ordvec` and + `ordvec-manifest` to `release.yml` / `pypi`. +6. Get explicit maintainer agreement to publish. 7. Push the version tag from `main` (signed): ```sh @@ -231,7 +240,10 @@ the OIDC exchange (no risk of a bad publish; just a failed run). `publish-pypi`, and `publish-manifest-pypi`. The two crates.io jobs use the same `crates-io` environment and may require separate approvals; the two PyPI jobs use the `pypi` environment and may also require separate approvals. - Required-reviewer approval is what authorises each registry push. + Each job must also clear the 30-minute wait timer. Because self-review is + blocked, the account that triggered the deployment cannot approve it; use the + other listed release approver in the Actions UI. Required-reviewer approval is + what authorises each registry push. - `publish-crate` and `publish-manifest-crate` first sha256-compare their repackaged `.crate` to the SLSA-attested artifact — if either diverges (toolchain drift, etc.) the job fails closed BEFORE the OIDC token is diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 1237abeb..e35b80f8 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -335,22 +335,23 @@ scoped to the wheel. ### 5.2 Risks -**THREAT-SUPPLY-001 (mitigated; residual = single-maintainer account -compromise): Release configuration and ownership.** The release **environments** -(`pypi`, `crates-io`) require **approval by the maintainer** and restrict -deployment to the **release-tag pattern `v[0-9]*.[0-9]*.[0-9]*`** (the -tag-triggered workflow runs on `refs/tags/...`, not `refs/heads/main`, so a -branch-only allowlist would deadlock publishing — see RELEASING.md). The -`require-ci-green` gate independently verifies the tag SHA has a successful -push-event CI run on `main`, and `main` itself is branch-protected (PR review, -no force-push) — so a release cannot be cut from an unmerged or attacker -branch, and no publish runs without an explicit human approval. The remaining residual is *maintainer-account compromise*: a -single owner both cuts the release tag and approves all publishes, so account takeover (or social -engineering) is not caught by a second human. *Mitigations:* strong 2FA / -passkeys on the maintainer account; recruiting a **second owner/maintainer** -(also an open OpenSSF Best-Practices item) — which would additionally make a -deployment **wait timer** worthwhile (a second party able to cancel a bad -release during the window). See [`RELEASING.md`](RELEASING.md). +**THREAT-SUPPLY-001 (mitigated; residual = release-approver account +compromise / collusion): Release configuration and ownership.** The release +**environments** (`pypi`, `crates-io`) list `Fieldnote-Echo` and `toadkicker` as +required reviewers, enable **prevent self-review**, enforce a **30-minute wait +timer**, and restrict deployment to the **release-tag pattern +`v[0-9]*.[0-9]*.[0-9]*`** (the tag-triggered workflow runs on +`refs/tags/...`, not `refs/heads/main`, so a branch-only allowlist would +deadlock publishing — see RELEASING.md). The `require-ci-green` gate +independently verifies the tag SHA has a successful push-event CI run on `main`, +and `main` itself is branch-protected (PR review, no force-push) — so a release +cannot be cut from an unmerged or attacker branch, and no publish runs without +an explicit human approval by a listed release approver who did not trigger the +deployment. The remaining residual is compromise or misuse of an eligible +approver account, or collusion between release participants. *Mitigations:* +strong 2FA / passkeys on both approver accounts, a small reviewed approver list, +and the 30-minute deployment window for the non-triggering approver to inspect +or cancel a bad release. See [`RELEASING.md`](RELEASING.md). **THREAT-SUPPLY-002 (mitigated): Release immutability and tag integrity.** Published artifacts are **immutable by registry design** — crates.io is @@ -369,7 +370,7 @@ the tag pattern `v[0-9]*.[0-9]*.[0-9]*`, and `require-ci-green` independently verifies the tag SHA has a successful push-event CI run on `main` — a SHA that only exists via a PR merge to the protected branch. *Residual:* draft / non-release tags are not covered by release immutability, and — as with the registries — these GitHub controls -ultimately trust the single maintainer account; that residual folds into +ultimately trust the release approver set; that residual folds into THREAT-SUPPLY-001. **THREAT-SUPPLY-003 (P3): Typosquatting adjacent names.** Namespace-adjacent @@ -491,7 +492,7 @@ blast radius of a compromised dependency separately. | THREAT-FFI-003 | FFI | Binding | Accidental telemetry through ABI stats | Low | Low | **Mitigated** — caller-owned stats, no logging | | THREAT-FFI-004 | FFI | Binding | Concurrent input mutation during released-GIL call | Medium | Medium | **P2** — documented contract | | THREAT-FFI-005 | FFI | Binding | Unsanitized path forwarding | Medium | Medium | **P2** — documented contract | -| THREAT-SUPPLY-001 | Supply chain | Config | Release config / single-owner | Low | Critical | **Mitigated** (reviewer-gated release-tag deployment + `require-ci-green` main-SHA gate); residual = account compromise / 2nd owner | +| THREAT-SUPPLY-001 | Supply chain | Config | Release config / dual-approver gate | Low | Critical | **Mitigated** (two approvers, self-review blocked, 30-minute wait timer, `require-ci-green` main-SHA gate); residual = approver compromise / collusion | | THREAT-SUPPLY-002 | Supply chain | Config | Release immutability / tag integrity | Low | High | **Mitigated** — registries immutable; GitHub immutable releases on + `main` protected | | THREAT-SUPPLY-003 | Supply chain | Config | Typosquatting adjacent names | Medium | Medium | P3 | | THREAT-QUERY-001 | Resource | Deployment | Batch / `k` exhaustion in serving | Medium | Medium | **P2** — deployment docs | @@ -527,8 +528,7 @@ x86_64/aarch64 unsafe paths (SIMD-004). 1. Document recommended `nq` / `k` / corpus bounds for single-process serving in the Rust and Python API docs (THREAT-QUERY-001). -**Later (not release blockers):** a second maintainer/owner (then a release -wait timer becomes meaningful); stronger deployment-specific manifest +**Later (not release blockers):** stronger deployment-specific manifest trust-policy UX such as external signatures/HMACs if a deployment requires tamper-evidence beyond `ordvec-manifest`'s hash-bound sidecar verification (DESER-002); a `safe_copy=True` FFI isolation option (FFI-001); layering ASAN diff --git a/tests/release_environment_settings.sh b/tests/release_environment_settings.sh index 2e0a4bbd..11f90639 100755 --- a/tests/release_environment_settings.sh +++ b/tests/release_environment_settings.sh @@ -7,7 +7,9 @@ set -euo pipefail REPO="${REPO:-Project-Navi/ordvec}" -EXPECTED_REVIEWER="${EXPECTED_REVIEWER:-Fieldnote-Echo}" +EXPECTED_REVIEWER_COUNT="${EXPECTED_REVIEWER_COUNT:-2}" +EXPECTED_REVIEWERS="${EXPECTED_REVIEWERS:-User:Fieldnote-Echo, User:toadkicker}" +EXPECTED_WAIT_TIMER="${EXPECTED_WAIT_TIMER:-30}" EXPECTED_POLICY="${EXPECTED_POLICY:-v[0-9]*.[0-9]*.[0-9]*}" ENVIRONMENTS=(crates-io pypi) @@ -47,8 +49,9 @@ check_environment() { 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 env_name required_rule_count reviewer_count reviewer_summary prevent_self_review local custom_branch_policies protected_branches + local wait_rule_count wait_timer local policy_total policy_summary policy_type policy_name echo "Auditing ${REPO} environment ${env}..." @@ -57,11 +60,14 @@ check_environment() { (.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(", ")), + ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | "\(.type):\(.reviewer.login // .reviewer.slug // .reviewer.name // "unknown")"] | sort | join(", ")), + ([.protection_rules[]? | select(.type == "required_reviewers") | .prevent_self_review] | first // false | tostring), + ([.protection_rules[]? | select(.type == "wait_timer")] | length | tostring), + ([.protection_rules[]? | select(.type == "wait_timer") | .wait_timer] | first // "" | tostring), (.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" + IFS=$'\t' read -r env_name required_rule_count reviewer_count reviewer_summary prevent_self_review wait_rule_count wait_timer custom_branch_policies protected_branches <<< "$env_data" [ "$env_name" = "$env" ] \ || fail "${env}: environment not found" @@ -69,10 +75,18 @@ check_environment() { [ "$required_rule_count" = "1" ] \ || fail "${env}: expected exactly one required_reviewers protection rule; found ${required_rule_count}" - [ "$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}" + [ "$reviewer_count" = "$EXPECTED_REVIEWER_COUNT" ] \ + || fail "${env}: expected ${EXPECTED_REVIEWER_COUNT} required reviewers (${EXPECTED_REVIEWERS}); found ${reviewer_count} (${reviewer_summary:-none})" + [ "$reviewer_summary" = "$EXPECTED_REVIEWERS" ] \ + || fail "${env}: expected required reviewers ${EXPECTED_REVIEWERS}; found ${reviewer_summary:-none}" + + [ "$prevent_self_review" = "true" ] \ + || fail "${env}: expected required_reviewers.prevent_self_review == true; found ${prevent_self_review}" + + [ "$wait_rule_count" = "1" ] \ + || fail "${env}: expected exactly one wait_timer protection rule; found ${wait_rule_count}" + [ "$wait_timer" = "$EXPECTED_WAIT_TIMER" ] \ + || fail "${env}: expected wait_timer ${EXPECTED_WAIT_TIMER} minutes; found ${wait_timer:-none}" [ "$custom_branch_policies" = "true" ] \ || fail "${env}: expected deployment_branch_policy.custom_branch_policies == true; found ${custom_branch_policies}" @@ -97,7 +111,7 @@ check_environment() { [ "$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}." + echo "OK: ${env} requires ${EXPECTED_REVIEWERS}, blocks self-review, waits ${EXPECTED_WAIT_TIMER} minutes, and only tag:${EXPECTED_POLICY}." } for env in "${ENVIRONMENTS[@]}"; do