Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
46 changes: 29 additions & 17 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 20 additions & 20 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
32 changes: 23 additions & 9 deletions tests/release_environment_settings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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}..."
Expand All @@ -57,22 +60,33 @@ 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"

[ "$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}"
Expand All @@ -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
Expand Down
Loading