From 4ad643efd9d5cc1138fd85d0d716aad241ccbc8b Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 10:24:06 -0500 Subject: [PATCH 1/4] Public export snapshot from bran-dev 94b66ce Adds the Sigstore keyless release contract and workflow, and rewrites the README around install steps, real command output, and the offline-or-connected split. --- .bran-export.json | 36 +- .github/workflows/release.yml | 178 ++++++++ README.md | 407 +++++++++--------- crates/bran-core/src/lib.rs | 53 ++- .../release/valid-exact-release-manifest.json | 4 +- schemas/bran-release-manifest.schema.json | 17 +- tools/ci/release-check.sh | 21 +- tools/ci/release_contract_check.py | 33 +- tools/ci/release_seal.py | 224 ++++++---- 9 files changed, 620 insertions(+), 353 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.bran-export.json b/.bran-export.json index c677ac1..532a217 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "4e28fd5f0d45d47b05f97bc35abbd6e2c30b9c61", + "source_commit": "94b66ce2ea63b434a7571ef614121ecb5ee963bd", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -23,6 +23,12 @@ "bytes": 696, "sha256": "75002eee19da62eeb11dbc9c86eb67d6fde08635d3277e41298e887305f92128" }, + { + "path": ".github/workflows/release.yml", + "mode": "100644", + "bytes": 6636, + "sha256": "584fb7bb2ceaab988be2500a7ab8598ed6ab073ce410546e7808ad5d5e3749a7" + }, { "path": ".gitignore", "mode": "100644", @@ -62,8 +68,8 @@ { "path": "README.md", "mode": "100644", - "bytes": 11524, - "sha256": "0baf93700cc37a3e0cdd501725e135f99d915c6b628889baf69b1d4d39bc878d" + "bytes": 9773, + "sha256": "e1e78f08b7f5bfe79f6a922d86df0d14de8a10d6e0771b2e602afda6ce502c3d" }, { "path": "assets/brand/bran-repository-raven.png", @@ -254,8 +260,8 @@ { "path": "crates/bran-core/src/lib.rs", "mode": "100644", - "bytes": 24814, - "sha256": "dd2d8c4c2f66b171aa149423b805dd5c92de6d88b8ba4f6e5cd3f3587fafccd2" + "bytes": 25540, + "sha256": "7823d50d4b59746c09635472364a415123afadfac1845b3345250e8486f5118a" }, { "path": "crates/bran-core/src/metadata/mod.rs", @@ -560,8 +566,8 @@ { "path": "fixtures/release/valid-exact-release-manifest.json", "mode": "100644", - "bytes": 2900, - "sha256": "5e47f7482fbcdd38ddf7d8e32f25e2bb83cefef8c3b1e948472ac4317be694da" + "bytes": 3067, + "sha256": "1f5ac7c18b2519b2132171250e4f163392bc966df5f18cc17c5d2c464c5ec669" }, { "path": "fixtures/repair/rollback-v1.json", @@ -596,8 +602,8 @@ { "path": "schemas/bran-release-manifest.schema.json", "mode": "100644", - "bytes": 7321, - "sha256": "cba9b364722a0d68abb25eb54dbd6ef8b79333b377c639357d66165702bbf0d6" + "bytes": 7579, + "sha256": "e6425b8e29e09f399c72eb69e440002d3ba73f161b7551a883df08b36a493674" }, { "path": "schemas/bran-repository-policy.schema.json", @@ -698,20 +704,20 @@ { "path": "tools/ci/release-check.sh", "mode": "100755", - "bytes": 1093, - "sha256": "29360f0ac6fe9701eb42c3357428f135d8f62028aeb2fa2a7bccd9290de8aac5" + "bytes": 1471, + "sha256": "bf04ac9e19e67ba4e4b84255cb25d44b4f8980f8ec5e7f9839f8e01f19cd9bb9" }, { "path": "tools/ci/release_contract_check.py", "mode": "100644", - "bytes": 11801, - "sha256": "36f0aa9098e350410d057cc1fe387d3e053816ab56c5658d6cdebbad5da1ccb2" + "bytes": 12318, + "sha256": "d37bb1c35618214d4d58b52d5e317c68bdd1864b0d9d097c748f62247aed5106" }, { "path": "tools/ci/release_seal.py", "mode": "100755", - "bytes": 22511, - "sha256": "32063c65aaff99ddc39ed47fb07ace8b30a022d9f3e177ce35469978ab5fb302" + "bytes": 24663, + "sha256": "7d6fa282b8ca614a135dbf90e1504558b40e3195e8352d8f2abde334393eaee8" }, { "path": "tools/ci/test-budget.json", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3ead78c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,178 @@ +name: release + +on: + push: + tags: + - "bran-v*" + +jobs: + build: + name: build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + # Least privilege: building needs to read the tag and nothing else. Only the + # sign job may write releases or mint an OIDC token. + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + - target: x86_64-apple-darwin + runner: macos-13 + - target: aarch64-apple-darwin + runner: macos-14 + - target: x86_64-pc-windows-msvc + runner: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Build release archive + run: ./tools/ci/build-release.sh --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist + - uses: actions/upload-artifact@v4 + with: + name: artifact-${{ matrix.target }} + path: dist/ + + sign: + name: Sign checksums and emit manifest + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: artifact-* + path: dist + merge-multiple: true + - name: Produce SHA256SUMS + run: | + python3 - "${{ github.ref_name }}" <<'PY' + import hashlib + import pathlib + import sys + + tag = sys.argv[1] + targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + ] + names = [ + f"{tag}-{target}.zip" if target == "x86_64-pc-windows-msvc" else f"{tag}-{target}.tar.gz" + for target in targets + ] + dist = pathlib.Path("dist") + lines = [] + for name in sorted(names): + digest = hashlib.sha256((dist / name).read_bytes()).hexdigest() + lines.append(f"{digest} {name}") + (dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") + PY + - uses: sigstore/cosign-installer@v3 + - name: Sign SHA256SUMS with cosign keyless + run: cosign sign-blob --yes --bundle dist/SHA256SUMS.sigstore dist/SHA256SUMS + - name: Emit bran-release-manifest.json + env: + TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + CERTIFICATE_IDENTITY: https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/${{ github.ref_name }} + run: | + python3 - <<'PY' + import hashlib + import json + import os + import pathlib + from datetime import datetime, timezone + + tag = os.environ["TAG"] + source_commit = os.environ["SOURCE_COMMIT"] + certificate_identity = os.environ["CERTIFICATE_IDENTITY"] + issuer = "https://token.actions.githubusercontent.com" + dist = pathlib.Path("dist") + + def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + def media_type(name): + return "application/zip" if name.endswith(".zip") else "application/gzip" + + targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + ] + names = [ + f"{tag}-{target}.zip" if target == "x86_64-pc-windows-msvc" else f"{tag}-{target}.tar.gz" + for target in targets + ] + assets = [ + { + "name": name, + "url": f"https://github.com/alphazede/bran/releases/download/{tag}/{name}", + "sha256": digest(dist / name), + "media_type": media_type(name), + } + for name in names + ] + sums_digest = digest(dist / "SHA256SUMS") + sig_digest = digest(dist / "SHA256SUMS.sigstore") + assets += [ + {"name": "SHA256SUMS", + "url": f"https://github.com/alphazede/bran/releases/download/{tag}/SHA256SUMS", + "sha256": sums_digest, "media_type": "text/plain"}, + {"name": "SHA256SUMS.sigstore", + "url": f"https://github.com/alphazede/bran/releases/download/{tag}/SHA256SUMS.sigstore", + "sha256": sig_digest, "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + ] + bundle = json.loads((dist / "SHA256SUMS.sigstore").read_text(encoding="utf-8")) + signed_at = datetime.fromtimestamp( + bundle["logEntry"]["integratedTime"], timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ") + lockfile_digest = digest(pathlib.Path("Cargo.lock")) + manifest = { + "schema_version": "1.0.0", + "tag": tag, + "repository": "alphazede/bran", + "source_commit": source_commit, + "lockfile_sha256": lockfile_digest, + "immutable": True, + "manifest_asset": "bran-release-manifest.json", + "assets": assets, + "checksums": {"asset": "SHA256SUMS", "algorithm": "sha256", "sha256": sums_digest}, + "signature": { + "asset": "SHA256SUMS.sigstore", + "format": "sigstore-bundle", + "certificate_identity": certificate_identity, + "certificate_oidc_issuer": issuer, + "signed_at": signed_at, + }, + "provenance": { + "format": "https://slsa.dev/provenance/v1", + "predicate_type": "https://slsa.dev/provenance/v1", + "source_repository": "alphazede/bran", + "source_commit": source_commit, + "lockfile_sha256": lockfile_digest, + "build_type": "https://alphazede.dev/bran/build/v1", + }, + } + (dist / "bran-release-manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + PY + - uses: actions/upload-artifact@v4 + with: + name: release-files + path: dist/ diff --git a/README.md b/README.md index 9f1b2a4..1e6eea7 100644 --- a/README.md +++ b/README.md @@ -7,238 +7,235 @@ tags: resource: https://github.com/alphazede/bran --- -# BRAN +# BRAN — deterministic code search and context packets for LLM agents -![BRAN seated between two ravens beneath the memory tree](assets/brand/bran-repository-raven.png) +**BRAN is a local-first Rust CLI for deterministic code search: it ranks a +repository offline and assembles a bounded context packet, so AI agents get the +right files without searching for them.** No embeddings and no index server. It +runs fully offline, or connected to a model you choose — an API key is optional +and unused by default. -BRAN helps you understand and validate a repository locally. Use the headless -`bran` command in scripts and agent workflows, or open the optional terminal -interface to browse. Scanning, focused evidence packets, validation, and -offline browsing work without an agent account. +[![CI](https://github.com/alphazede/bran/actions/workflows/bran-fast.yml/badge.svg)](https://github.com/alphazede/bran/actions/workflows/bran-fast.yml) +![Rust](https://img.shields.io/badge/rust-stable-orange) +![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue) -## Build and try it +## Why not just grep? -Run the fast checks: +`rg` answers "which lines contain this string." An agent asking "where is +authentication handled?" gets an unranked wall of matches and burns tokens +sorting it. BRAN answers "which files are authoritative for this question," +ranked, with the reason attached. + +| | ripgrep / grep | Embedding RAG | BRAN | +|---|---|---|---| +| Ranking | none | similarity | declared authority + path + body + metadata | +| Determinism | yes | no | yes — identical input, identical output | +| Needs a model | no | yes | optional — works either way | +| Needs an index server | no | usually | no | +| Reports a miss | n/a | rarely | yes, explicitly | +| Offline | yes | rarely | yes | + +## Install + +Prebuilt archives for Linux, macOS, and Windows are attached to each release: ```sh -./tools/ci/check.sh --fast +https://github.com/alphazede/bran/releases/download/bran-v0.1.0/ ``` -Try a quick smoke test from the repository root: +Or build from source: ```sh -cargo run --quiet --bin bran -- smoke +cargo install --git https://github.com/alphazede/bran --tag bran-v0.1.0 bran-cli ``` -The command prints a versioned JSON response. Start the TUI with: +## Quickstart + +Rank the sources for a question: ```sh -cargo run --quiet --bin bran -- tui +bran query . "valid_sha256" | jq '{status, source_rankings: .data.source_rankings[:3], metrics}' ``` -On first launch, BRAN shows the requested and available settings for offline -mode, SQZ, connected agents, voice, history, and saved chats. If a capability -is unavailable, BRAN says so instead of pretending it worked. The default setup -is offline, read-only, and keeps no conversation history. +```json +{ + "status": "ok", + "source_rankings": [ + { "rank": 1, "locator": "crates/bran-core/src/agent/runtime.rs", "match_reason": "exact:body" }, + { "rank": 2, "locator": "crates/bran-core/src/agent/delegate.rs", "match_reason": "exact:body" }, + { "rank": 3, "locator": "crates/bran-core/src/adapters/connected.rs", "match_reason": "exact:body" } + ], + "metrics": { + "candidate_source_bytes": 2702707, + "selected_source_bytes": 133660, + "context_bytes_avoided": 2569047, + "estimated_tokens": 33415 + } +} +``` -To cap a connected task, set `tokens=N`. BRAN treats this as a requested host -limit until the connected adapter confirms enforcement. Leaving it unset does -not block connected work or imply that a token limit is enforced. Version 2 -settings migrate the old numeric default to `unset`; set `tokens=N` again if -you want an explicit limit. The separate 65,536-byte answer limit protects -storage. It is not a token limit or token-usage measurement. +That is 2.7 MB of candidate sources narrowed to 134 KB. Trim the output with +`jq` so BRAN saves context instead of consuming it. -During onboarding, you can choose `agent=`, `model=`, and `reasoning=` values, -including `reasoning=max`, for the current TUI session. You can also choose -`retention=none|structured|saved`. These options control BRAN's existing -history and saved-chat behavior. They never accept credentials or change -global configuration, and provider-side conversation retention remains -disabled. +## A miss looks like a miss -After onboarding, inspect local readiness without contacting an account: +An agent cannot tell a good answer from a confident wrong one. So when a +high-specificity entity has no match, BRAN returns nothing and says why, +instead of padding the result with files that matched the generic words around +it: ```sh -bran doctor --onboarding -bran doctor --agent -bran agents list +bran query . "nonexistent-collector-xyz" ``` -Both doctor modes are read-only. Their JSON output shows unavailable -capabilities and attestation details, and confirms that they made no provider, -authentication, or network calls. `bran doctor --agent` continues to return -validation status until the connected runtime and host attestation are active, -even when `local_setup_ready` is true. See -[Agent setup](docs/integrations/agent-setup.md) for the two supported setup -journeys, reasoning and tool recipes, no-session operation, and the offline -return check. To let an external agent host call BRAN, install the instructions -in [`skill/use-bran`](skill/use-bran/SKILL.md). - -## Make an agent actually use BRAN - -Giving an agent access to BRAN is not enough. Without a timely reminder, an -agent usually reaches for built-in search tools because they are always -available and never report `unavailable`. In one dev-node session on -2026-07-25, a BRAN banner appeared on every turn while the agent still used raw -search dozens of times without invoking BRAN. - -Two structural issues caused that behavior: - -1. A session-start or prompt-time reminder is stale by the time the agent forms - a search. The reminder needs to run on the search tool call itself. -2. BRAN requires a native `.bran/policy.yaml` at the repository root. Without - one, `bran_status: unavailable` is the correct result, and ordinary - repository discovery is the correct fallback. Coverage is a precondition - for adoption. - -### Add coverage before reminders - -Audit target repositories for `.bran/policy.yaml` before wiring hooks. Do not -nag an agent toward BRAN in a repository where it is unavailable; include the -known coverage gaps in local injected context instead. - -When existing public-facing Markdown cannot carry BRAN classification -frontmatter, keep the native index private, classify existing documents with -`legacy_baseline`, and exclude private or generated state with `.branignore`. -This provides repository coverage without changing the published Markdown. - -### Remind the agent at search time - -Use a `PreToolUse` command hook and match the tool names emitted by the actual -harness. Tool vocabularies differ: - -| Harness | Search path | Matcher | -|---|---|---| -| Claude Code | Native `Grep`/`Glob`, plus raw search through its shell tool | `Grep\|Glob\|Bash` | -| Codex | Unified shell commands such as `rg`, `grep`, `git grep`, and `find` | `Bash` | - -If a Codex surface exposes a dedicated search function, match its reported tool -name as well. Use `/hooks` to inspect the active hook sources and observed tool -names instead of assuming that another harness's matcher vocabulary applies. -For a broad matcher such as `Bash`, the script should inspect `tool_input` and -stay silent unless the command is a repository search. Resolve native coverage -from the call's current working directory on every invocation instead of -hard-coding a list of covered or uncovered repositories; that list becomes -wrong as soon as a policy is added or removed. - -Keep the hook in a script file and reference it by absolute path. Both -harnesses send a JSON payload on stdin. A Codex `PreToolUse` reminder returns an -event-specific JSON object like this: - ```json { - "systemMessage": "BRAN_SEARCH_ALERT: raw rg repository search requested in a BRAN-covered checkout.", - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "additionalContext": "Use the verified BRAN binary for this repository-knowledge search." - } + "status": "ok", + "source_rankings": [], + "warnings": ["unmatched_query_terms: nonexistent-collector-xyz"] } ``` -Codex treats non-empty hook stdout as JSON. Plain text on stdout causes an -`invalid ... JSON output` hook failure. Exit successfully with no output when -the hook does not apply. +Command success is not evidence coverage. Empty results are a feature. -Codex also requires review of every new or changed non-managed hook definition. -Open `/hooks`, inspect the source and exact command, and trust it; until then, -Codex intentionally skips the changed hook. Test the stored command first, and -start a fresh session if an already-running session still has the previous -matcher set loaded. Do not use a trust-bypass flag as normal installation -guidance. +## Commands -### Make raw-search fallback visible +| Command | Purpose | +|---|---| +| `bran query ` | Rank the sources for a request | +| `bran packet ` | Assemble a bounded context packet | +| `bran check ` | Validate against `okf-v0.1`, `okf-v0.2`, or `bran-strict` | +| `bran maintain ` | Bounded repair under explicit authority | +| `bran tui` | Browse the repository offline | +| `bran doctor --onboarding\|--agent` | Read-only local readiness check | +| `bran get ` | Retrieve a stored result | -A search hook sees the raw tool call but cannot reliably prove that a BRAN -query succeeded earlier in the conversation. Treat every matching raw search -as an observable fallback: return a top-level `systemMessage` beginning with a -stable marker such as `BRAN_SEARCH_ALERT`, and use `additionalContext` to make -the agent report whether BRAN was used and why the fallback is still needed. -This lets an owner find adoption loopholes without blocking legitimate -diagnostic searches or maintaining fragile per-session state. +Every command emits versioned JSON. `query`, `packet`, `check`, and `tui` need +no account and make no network calls. -In a covered repository, the alert should require `bran_status` plus a bounded -fallback reason. In an uncovered repository, it should explicitly report -`bran_status: unavailable` and allow ordinary discovery. Stay silent for -unrelated shell commands so the warning remains useful instead of becoming -background noise. +## The schema layer: OKF -The injected context should tell the agent to: +BRAN ranks on declared authority, not guesswork. That declaration is the +Open Knowledge Format, or [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog), +Google's open spec. YAML frontmatter turns ordinary markdown into a queryable knowledge graph: -- Resolve the pinned BRAN binary and verify its SHA-256 against the release pin. -- Use ordinary discovery immediately when the repository has no native policy. -- Trim query output so BRAN saves context instead of consuming it. -- Report `bran_status` as `hit`, `miss`, `stale`, `conflict`, or `unavailable`. +```yaml +--- +type: Concept +title: Ranking precedence +status: active +tags: [developer] +resource: https://github.com/alphazede/bran +--- +``` -For example, keep the highest-ranked sources and top-level metrics while -dropping the duplicate provenance payload: +`type` is the only required field. Optional families cover provenance +(`sources`, `usage_window`), trust (`generated`, `verified`), and lifecycle +(`status`, `stale_after`). ```sh -bran query "" | - jq '{status, source_rankings: .data.source_rankings[:8], metrics, warnings, failures}' +bran check . okf-v0.2 +``` + +```json +{ + "selected_profile": "okf-v0.2", + "selected_passed": true, + "okf_compatibility": { "profile": "okf-v0.1", "status": "pass" }, + "okf_v0_2": { "profile": "okf-v0.2", "status": "pass" }, + "bran_strict": { "profile": "bran-strict", "status": "pass" } +} ``` -### Test both hook directions +All three results are reported independently and only the selected profile +controls the exit code, so OKF conformance is never confused with house rules. + +## Export the knowledge graph + +`bran_core::export` emits an Obsidian-compatible vault from the graph, so a +repository can be browsed visually. See +[`examples/obsidian/usage.rs`](examples/obsidian/usage.rs). + +## Offline or connected — both are first class + +BRAN runs either way, and the same commands work in both modes. + +**Offline** is the default and needs no account, no key, and no network. +Scanning, ranking, packets, validation, and the TUI are complete on their own — +this is not a trial tier. -Do not assume that a stored hook configuration is valid. Inline shell embedded -in JSON is easy to damage through escaping, so prefer an executable script and -test the command exactly as stored: +**Connected** adds a model that reads what BRAN selected and answers with +citations. It is opt-in per invocation. + +### Where the models go + +If you connect a model, put it in the middle tier rather than the top: + +1. **BRAN** decides *which* files matter. Deterministic, offline, free. +2. **A fast or local model** — a Flash-class model, or something on your own + hardware — reads those files and condenses them. +3. **The frontier model** receives that clean, bounded context and reasons. + +The expensive model should never be the thing hunting through a repository. +Retrieval is a search problem, not a reasoning problem. + +### Connect a model + +BRAN has **no API-key flag and never copies credentials**. You point it at a +profile; the account reference becomes an opaque one-way handle before any +request, receipt, or diagnostic is written. + +1. Create a project-local `.bran/settings.conf` with `profile=connected-agent`. +2. Describe the connection through the environment — a reference, not a secret: + + ```sh + export BRAN_AGENT_PROFILE= + export BRAN_AGENT_PROVIDER= + export BRAN_AGENT_MODEL= + export BRAN_AGENT_REASONING=medium + export BRAN_AGENT_ACCOUNT_REF= + ``` + +3. Check what is actually available before relying on it: + + ```sh + bran agents list + bran doctor --agent + ``` + +4. Run a bounded, grounded request: + + ```sh + bran -p --agent --reasoning medium --tools read,search \ + --trust-current-root "which module owns frontmatter validation?" + ``` + +`--tools read,search` limits it to repository read and search. `--no-session` +disables retention. `--offline` forces the deterministic profile even when a +profile is configured, so you can always fall back: ```sh -echo '{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"x"}}' | - /absolute/path/bran-search.sh +bran -p --agent --offline --no-session "offline return proof" ``` -Confirm that a matching payload produces valid JSON with non-empty -`additionalContext`. Then send an unrelated payload and confirm the hook emits -nothing. A noisy hook that fires on every command will eventually be disabled. - -In the 2026-07-25 dev-node observation, one repository query narrowed 5.9 MB of -candidate sources to 411 KB, placed the correct file at rank 2, and reported -about 102,000 estimated tokens of context avoided. This is an observed result, -not a general performance guarantee, and it only helps when the hook fires and -the returned JSON is trimmed. - -Connected tasks require a valid project-local `.bran/settings.conf` with -`profile=connected-agent`. Set `BRAN_AGENT_PROFILE`, `BRAN_AGENT_PROVIDER`, -`BRAN_AGENT_MODEL`, `BRAN_AGENT_REASONING`, and `BRAN_AGENT_ACCOUNT_REF` to -describe the agent connection. -`BRAN_EXTERNAL_HOST_EXECUTABLE`, `BRAN_EXTERNAL_HOST_SHA256`, and -`BRAN_SQZ_EXECUTABLE` identify the local adapters. The external host timeout is -30 seconds by default; set `BRAN_EXTERNAL_HOST_TIMEOUT_SECONDS` to a whole -number from 1 through 600 for a slower call. - -These values are references, not credentials. BRAN has no API-key flag and -never copies credentials. It validates every value and converts the account -reference into an opaque, one-way handle before creating requests, receipts, -diagnostics, or `agents list` output. The raw environment value is never -echoed. - -The approved SQZ 1.1.1 digest identifies the verified platform artifact. -Platforms without that exact artifact report connected SQZ as unavailable. -`bran packet` also honors project `sqz=true` without contacting a model or -provider, and returns the post-policy packet with a complete SQZ receipt. When -SQZ is off, BRAN makes no SQZ process call. When it is on, BRAN fails visibly if -the executable, identity, fidelity, DLP, or output contract is invalid or -unavailable. - -Settings alone never give an agent permission to run. Add -`--trust-current-root` to each connected `bran -p` call, or enter -`trust-current-root` for the current TUI session. BRAN scans the repository, -builds a bounded evidence packet, applies the configured SQZ policy, and then -calls the configured host. It stores completed results and lossless artifacts -under the IDs in `receipt.stored_result_ref`. Storage is limited by item count, -total bytes, and TTL, and remains separate from conversation history. Run -`bran get ` to retrieve the decoded answer and its citations. +If a capability is unavailable, BRAN says `unavailable` rather than pretending +it worked. Requested and effective capability are always reported separately. -## Releases +## Use it with an agent -BRAN does not have a published release yet. When releases begin, each version -will use an exact `bran-vX.Y.Z` tag. Downloads will be available under: +Giving an agent access is not enough. Without a reminder it reaches for +built-in search, which is always available and never reports `unavailable`. +Install [`skill/use-bran`](skill/use-bran/SKILL.md) for the agent-facing +instructions, and see [Agent setup](docs/integrations/agent-setup.md) for hook +recipes, reasoning and tool configuration, and the offline return check. -```text -https://github.com/alphazede/bran/releases/download/bran-vX.Y.Z/ -``` +## Releases -Each release will include these five platform archives: +Each release uses an exact `bran-vX.Y.Z` tag. Downloads live under +`https://github.com/alphazede/bran/releases/download/bran-vX.Y.Z/` — exact tags +only, never `latest`. + +Five platform archives are published: - `bran-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz` - `bran-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz` @@ -246,21 +243,41 @@ Each release will include these five platform archives: - `bran-vX.Y.Z-aarch64-apple-darwin.tar.gz` - `bran-vX.Y.Z-x86_64-pc-windows-msvc.zip` -Each release will also include `SHA256SUMS`, `SHA256SUMS.sig`, and -`bran-release-manifest.json`. - -- `bran-release-manifest.json` records release provenance. -- SBOMs are not yet part of the release workflow. -- Release notes belong to the tagged release. Installation and downloads use - exact tags rather than `latest`. +Alongside them: `SHA256SUMS`, `SHA256SUMS.sigstore`, and +`bran-release-manifest.json`, which records release provenance. -To install a specific release with Cargo: +Signing is Sigstore keyless via GitHub OIDC — there is no long-lived key to +manage or leak. Verify a download: ```sh -cargo install --git https://github.com/alphazede/bran --tag bran-vX.Y.Z --locked bran-cli +cosign verify-blob SHA256SUMS \ + --bundle SHA256SUMS.sigstore \ + --certificate-identity "https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/bran-v0.1.0" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" +sha256sum -c SHA256SUMS --ignore-missing ``` +SBOMs are not yet part of the release workflow. + +## FAQ + +**Does BRAN need an API key?** No. A key or auth session is entirely optional. +Scanning, ranking, packets, validation, and the TUI are fully offline and make +no network calls. A connected mode exists and is opt-in. + +**If I add a model, which one?** A fast or local one. Use a Flash-class or +self-hosted model to read the files BRAN selected and hand the condensed result +to your frontier model. See [Where the models go](#where-the-models-go). + +**Does it replace RAG?** For code and docs, often yes. It separates retrieval +from reasoning, so a cheap deterministic step feeds the expensive model. + +**What languages does it support?** Ranking is language-agnostic; it operates on +paths, document bodies, structure, and OKF metadata. + +**Why did my query return nothing?** Because nothing matched. Check the +`unmatched_query_terms` warning — that is BRAN refusing to guess. + ## License -BRAN is available under your choice of the [Apache License 2.0](LICENSE-APACHE) -or the [MIT License](LICENSE-MIT). +Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE). diff --git a/crates/bran-core/src/lib.rs b/crates/bran-core/src/lib.rs index 8272e0b..504b395 100644 --- a/crates/bran-core/src/lib.rs +++ b/crates/bran-core/src/lib.rs @@ -30,7 +30,9 @@ use std::fmt; const DOWNLOAD_ROOT: &str = "https://github.com/alphazede/bran/releases/download/"; const CHECKSUMS: &str = "SHA256SUMS"; -const CHECKSUMS_SIGNATURE: &str = "SHA256SUMS.sig"; +const CHECKSUMS_SIGNATURE: &str = "SHA256SUMS.sigstore"; +const SIGNATURE_FORMAT: &str = "sigstore-bundle"; +const SIGSTORE_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; const MANIFEST: &str = "bran-release-manifest.json"; /// A validated immutable Bran release tag. @@ -117,12 +119,13 @@ pub struct DeclaredChecksums { pub sha256: String, } -/// Declared OpenPGP signature metadata (shape only). +/// Declared Sigstore keyless signature metadata (shape only). #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeclaredSignature { pub asset: String, pub format: String, - pub key_fingerprint: String, + pub certificate_identity: String, + pub certificate_oidc_issuer: String, pub signed_at: String, } @@ -145,7 +148,7 @@ pub struct DeclaredProvenance { /// truth for the contract). /// /// This verifies declared Slice 1.1 metadata structure and semantic binding only. -/// It does NOT fetch bytes, recompute digests, execute OpenPGP verification, +/// It does NOT fetch bytes, recompute digests, execute signature verification, /// or attest provenance. Those later cryptographic operations remain outside /// this slice. #[derive(Clone, Debug, Eq, PartialEq)] @@ -187,7 +190,8 @@ pub enum ReleaseVerificationError { MalformedChecksumsDigest(String), SignatureAssetMismatch { expected: String, actual: String }, SignatureFormatMismatch { expected: String, actual: String }, - MalformedSignatureFingerprint(String), + MalformedCertificateIdentity(String), + CertificateOidcIssuerMismatch { expected: String, actual: String }, MalformedSignatureTimestamp(String), ProvenanceFormatMismatch, ProvenancePredicateTypeMismatch, @@ -270,10 +274,16 @@ impl fmt::Display for ReleaseVerificationError { Self::SignatureFormatMismatch { expected, actual } => { write!(formatter, "signature.format must be {expected}: {actual}") } - Self::MalformedSignatureFingerprint(f) => { + Self::MalformedCertificateIdentity(i) => { write!( formatter, - "signature.key_fingerprint must be exactly 40 or 64 lowercase hex: {f}" + "signature.certificate_identity must be an https URL: {i}" + ) + } + Self::CertificateOidcIssuerMismatch { expected, actual } => { + write!( + formatter, + "signature.certificate_oidc_issuer must be {expected}: {actual}" ) } Self::MalformedSignatureTimestamp(t) => { @@ -328,7 +338,7 @@ impl ReleaseVerifier { /// The complete immutable (hashed) asset names for this release. /// /// Exactly seven assets are required and order-insensitive: - /// five platform archives plus SHA256SUMS and SHA256SUMS.sig. + /// five platform archives plus SHA256SUMS and SHA256SUMS.sigstore. /// The bran-release-manifest.json is declared via manifest_asset /// but is distributed without a self-digest entry in the hashed assets. /// @@ -406,15 +416,15 @@ impl ReleaseVerifier { /// - lowercase 64-hex lockfile_sha256 /// - immutable == true /// - manifest_asset == "bran-release-manifest.json" (distributed without self-digest) - /// - exactly seven order-insensitive hashed assets (5 platform + SHA256SUMS + SHA256SUMS.sig) + /// - exactly seven order-insensitive hashed assets (5 platform + SHA256SUMS + SHA256SUMS.sigstore) /// - exact tag/name/direct URL binding for every asset (no /latest) /// - each asset has lowercase 64-hex sha256 and non-blank media_type /// - checksums bound to the SHA256SUMS asset's sha256 (with correct asset/algorithm) - /// - OpenPGP signature metadata: asset, format=openpgp, exactly 40 or 64 lowercase hex fingerprint, strict UTC YYYY-MM-DDTHH:MM:SSZ signed_at (full Gregorian calendar/leap-day validated) + /// - Sigstore keyless signature metadata: asset, format=sigstore-bundle, https URL certificate_identity, exact certificate_oidc_issuer const, strict UTC YYYY-MM-DDTHH:MM:SSZ signed_at (full Gregorian calendar/leap-day validated) /// - SLSA v1 provenance: format/predicate consts, repository const, source_commit/lockfile_sha256 cross-bound to top level (40/64 hex), build_type https://... /// /// This verifies declared Slice 1.1 metadata structure/semantic binding only. - /// It does NOT fetch bytes, recompute digests, execute OpenPGP verification, + /// It does NOT fetch bytes, recompute digests, execute signature verification, /// or attest provenance—those later cryptographic operations remain outside this slice. /// /// Python (tools/ci/release_contract_check.py) is the fixture/schema semantic oracle. @@ -528,17 +538,23 @@ impl ReleaseVerifier { actual: sig.asset.clone(), }); } - if sig.format != "openpgp" { + if sig.format != SIGNATURE_FORMAT { return Err(ReleaseVerificationError::SignatureFormatMismatch { - expected: "openpgp".to_owned(), + expected: SIGNATURE_FORMAT.to_owned(), actual: sig.format.clone(), }); } - if !is_fingerprint(&sig.key_fingerprint) { - return Err(ReleaseVerificationError::MalformedSignatureFingerprint( - sig.key_fingerprint.clone(), + if !sig.certificate_identity.starts_with("https://") { + return Err(ReleaseVerificationError::MalformedCertificateIdentity( + sig.certificate_identity.clone(), )); } + if sig.certificate_oidc_issuer != SIGSTORE_OIDC_ISSUER { + return Err(ReleaseVerificationError::CertificateOidcIssuerMismatch { + expected: SIGSTORE_OIDC_ISSUER.to_owned(), + actual: sig.certificate_oidc_issuer.clone(), + }); + } if !is_strict_utc_datetime(&sig.signed_at) { return Err(ReleaseVerificationError::MalformedSignatureTimestamp( sig.signed_at.clone(), @@ -611,11 +627,6 @@ fn is_sha256(s: &str) -> bool { is_lowercase_hex(s, 64) } -fn is_fingerprint(s: &str) -> bool { - let l = s.len(); - (l == 40 || l == 64) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) -} - /// Exact strict UTC shape: YYYY-MM-DDTHH:MM:SSZ (20 bytes, ASCII digits + separators + Z). /// Then numeric range + real proleptic Gregorian calendar (incl. leap days) using /// divisibility rules (no external crate, matches Python stdlib datetime semantics). diff --git a/fixtures/release/valid-exact-release-manifest.json b/fixtures/release/valid-exact-release-manifest.json index 78695c5..bc4f9e6 100644 --- a/fixtures/release/valid-exact-release-manifest.json +++ b/fixtures/release/valid-exact-release-manifest.json @@ -13,9 +13,9 @@ { "name": "bran-v1.2.3-aarch64-apple-darwin.tar.gz", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/bran-v1.2.3-aarch64-apple-darwin.tar.gz", "sha256": "4444444444444444444444444444444444444444444444444444444444444444", "media_type": "application/gzip" }, { "name": "bran-v1.2.3-x86_64-pc-windows-msvc.zip", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/bran-v1.2.3-x86_64-pc-windows-msvc.zip", "sha256": "5555555555555555555555555555555555555555555555555555555555555555", "media_type": "application/zip" }, { "name": "SHA256SUMS", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/SHA256SUMS", "sha256": "6666666666666666666666666666666666666666666666666666666666666666", "media_type": "text/plain" }, - { "name": "SHA256SUMS.sig", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/SHA256SUMS.sig", "sha256": "7777777777777777777777777777777777777777777777777777777777777777", "media_type": "application/pgp-signature" } + { "name": "SHA256SUMS.sigstore", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/SHA256SUMS.sigstore", "sha256": "7777777777777777777777777777777777777777777777777777777777777777", "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json" } ], "checksums": { "asset": "SHA256SUMS", "algorithm": "sha256", "sha256": "6666666666666666666666666666666666666666666666666666666666666666" }, - "signature": { "asset": "SHA256SUMS.sig", "format": "openpgp", "key_fingerprint": "0123456789abcdef0123456789abcdef01234567", "signed_at": "2026-01-01T00:00:00Z" }, + "signature": { "asset": "SHA256SUMS.sigstore", "format": "sigstore-bundle", "certificate_identity": "https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/bran-v1.2.3", "certificate_oidc_issuer": "https://token.actions.githubusercontent.com", "signed_at": "2026-01-01T00:00:00Z" }, "provenance": { "format": "https://slsa.dev/provenance/v1", "predicate_type": "https://slsa.dev/provenance/v1", "source_repository": "alphazede/bran", "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "lockfile_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "build_type": "https://alphazede.dev/bran/build/v1" } } diff --git a/schemas/bran-release-manifest.schema.json b/schemas/bran-release-manifest.schema.json index d021695..fb1defa 100644 --- a/schemas/bran-release-manifest.schema.json +++ b/schemas/bran-release-manifest.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://schemas.alphazede.dev/bran/release-manifest/v1/schema.json", "title": "Bran exact-release manifest", - "description": "Non-circular release manifest contract. assets holds exactly seven hashed files (five platform archives + SHA256SUMS + SHA256SUMS.sig) in any order. manifest_asset is the const 'bran-release-manifest.json' with no self-digest inside. Build order: archives, SHA256SUMS (covers archives), SHA256SUMS.sig (signs sums), manifest (records digests/metadata, distributed unhashed). source_commit and provenance.source_commit are 40-hex git SHA-1; lockfile_sha256, asset sha256, checksums.sha256 are 64-hex. Asset order insignificant. key_fingerprint is exactly 40 or 64 lowercase hex; signed_at is the strict UTC subset exactly YYYY-MM-DDTHH:MM:SSZ (ASCII digits/separators only; year 0001-9999, real proleptic Gregorian/leap-day; pattern enforces no year 0000 / invalid m/d / non-leap Feb29 / Feb30 / 24h / 60minsec, format kept for Draft2020-12; hours 00-23, min/sec 00-59). Schema cannot bind dynamic tag/name/URL equality; x-semantic-oracle is the mandatory stdlib checker.", + "description": "Non-circular release manifest contract. assets holds exactly seven hashed files (five platform archives + SHA256SUMS + SHA256SUMS.sigstore) in any order. manifest_asset is the const 'bran-release-manifest.json' with no self-digest inside. Build order: archives, SHA256SUMS (covers archives), SHA256SUMS.sigstore (sigstore-bundle signs sums), manifest (records digests/metadata, distributed unhashed). source_commit and provenance.source_commit are 40-hex git SHA-1; lockfile_sha256, asset sha256, checksums.sha256 are 64-hex. Asset order insignificant. certificate_identity is the signing workflow's https OIDC subject URI; certificate_oidc_issuer is the exact GitHub Actions issuer const; signed_at is the strict UTC subset exactly YYYY-MM-DDTHH:MM:SSZ (ASCII digits/separators only; year 0001-9999, real proleptic Gregorian/leap-day; pattern enforces no year 0000 / invalid m/d / non-leap Feb29 / Feb30 / 24h / 60minsec, format kept for Draft2020-12; hours 00-23, min/sec 00-59). Schema cannot bind dynamic tag/name/URL equality; x-semantic-oracle is the mandatory stdlib checker.", "x-semantic-oracle": "tools/ci/release_contract_check.py", "type": "object", "additionalProperties": false, @@ -81,13 +81,16 @@ "signature": { "type": "object", "additionalProperties": false, - "required": ["asset", "format", "key_fingerprint", "signed_at"], + "required": ["asset", "format", "certificate_identity", "certificate_oidc_issuer", "signed_at"], "properties": { - "asset": { "const": "SHA256SUMS.sig" }, - "format": { "const": "openpgp" }, - "key_fingerprint": { + "asset": { "const": "SHA256SUMS.sigstore" }, + "format": { "const": "sigstore-bundle" }, + "certificate_identity": { "type": "string", - "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + "pattern": "^https://" + }, + "certificate_oidc_issuer": { + "const": "https://token.actions.githubusercontent.com" }, "signed_at": { "type": "string", @@ -182,7 +185,7 @@ "signatureAsset": { "allOf": [ { "$ref": "#/$defs/asset" }, - { "properties": { "name": { "const": "SHA256SUMS.sig" } } } + { "properties": { "name": { "const": "SHA256SUMS.sigstore" } } } ] } } diff --git a/tools/ci/release-check.sh b/tools/ci/release-check.sh index f52958e..d19ac90 100755 --- a/tools/ci/release-check.sh +++ b/tools/ci/release-check.sh @@ -3,14 +3,15 @@ set -eu usage() { - printf 'usage: %s --tag TAG --dist DIR [--dry-run-unsigned] [--fingerprint FINGERPRINT]\n' "$0" >&2 + printf 'usage: %s --tag TAG --dist DIR [--dry-run-unsigned] [--certificate-identity IDENTITY] [--certificate-oidc-issuer ISSUER]\n' "$0" >&2 exit 2 } tag= dist= dry= -fingerprint= +certificate_identity= +certificate_oidc_issuer= while [ $# -gt 0 ]; do case "$1" in --tag) @@ -23,9 +24,14 @@ while [ $# -gt 0 ]; do dist=$2 shift 2 ;; - --fingerprint) + --certificate-identity) [ $# -ge 2 ] || usage - fingerprint=$2 + certificate_identity=$2 + shift 2 + ;; + --certificate-oidc-issuer) + [ $# -ge 2 ] || usage + certificate_oidc_issuer=$2 shift 2 ;; --dry-run-unsigned) @@ -45,7 +51,10 @@ set -- python3 "$script_dir/release_seal.py" --tag "$tag" --dist "$dist" if [ -n "$dry" ]; then set -- "$@" "$dry" fi -if [ -n "$fingerprint" ]; then - set -- "$@" --fingerprint "$fingerprint" +if [ -n "$certificate_identity" ]; then + set -- "$@" --certificate-identity "$certificate_identity" +fi +if [ -n "$certificate_oidc_issuer" ]; then + set -- "$@" --certificate-oidc-issuer "$certificate_oidc_issuer" fi exec "$@" diff --git a/tools/ci/release_contract_check.py b/tools/ci/release_contract_check.py index cec498d..a11b217 100644 --- a/tools/ci/release_contract_check.py +++ b/tools/ci/release_contract_check.py @@ -13,10 +13,10 @@ SCHEMA_VERSION = "1.0.0" REPOSITORY = "alphazede/bran" +OIDC_ISSUER = "https://token.actions.githubusercontent.com" TAG_PATTERN = re.compile(r"bran-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z") GIT_SHA_PATTERN = re.compile(r"[0-9a-f]{40}\Z") -FINGERPRINT_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") STRICT_SIGNED_AT_REGEX = r"^(?:(?:000[1-9]|00[1-9][0-9]|0[1-9][0-9]{2}|[1-9][0-9]{3})-(?:(?:(?:0[13578]|1[02]))-(?:0[1-9]|[12][0-9]|3[01])|(?:(?:0[469]|11))-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)00)-02-29)T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$" SIGNED_AT_PATTERN = re.compile(STRICT_SIGNED_AT_REGEX) RELEASE_BASE = "https://github.com/alphazede/bran/releases/download" @@ -24,7 +24,8 @@ # Expected schema constants for drift detection (exact strings must match schema) # STRICT_SIGNED_AT_REGEX and EXPECTED_SIGNED_AT_PATTERN are identical (schema pattern uses $ anchors; datetime.strptime is semantic defense) -EXPECTED_FINGERPRINT_PATTERN = r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$" +EXPECTED_CERTIFICATE_IDENTITY_PATTERN = r"^https://" +EXPECTED_OIDC_ISSUER = OIDC_ISSUER EXPECTED_SIGNED_AT_PATTERN = STRICT_SIGNED_AT_REGEX EXPECTED_SIGNED_AT_FORMAT = "date-time" @@ -48,8 +49,8 @@ def is_git_sha(value: Any) -> bool: return isinstance(value, str) and GIT_SHA_PATTERN.fullmatch(value) is not None -def is_fingerprint(value: Any) -> bool: - return isinstance(value, str) and FINGERPRINT_PATTERN.fullmatch(value) is not None +def is_certificate_identity(value: Any) -> bool: + return isinstance(value, str) and value.startswith("https://") def is_strict_utc_datetime(value: Any) -> bool: @@ -82,7 +83,7 @@ def expected_asset_names(tag: str) -> tuple[str, ...]: f"{tag}-aarch64-apple-darwin.tar.gz", f"{tag}-x86_64-pc-windows-msvc.zip", "SHA256SUMS", - "SHA256SUMS.sig", + "SHA256SUMS.sigstore", ) @@ -163,13 +164,16 @@ def validate_manifest(manifest: Any) -> list[str]: errors.append("checksums.sha256 must match the SHA256SUMS asset") signature = manifest["signature"] - if not is_object_with_keys(signature, {"asset", "format", "key_fingerprint", "signed_at"}): - errors.append("signature must contain exactly asset, format, key_fingerprint, and signed_at") + signature_keys = {"asset", "format", "certificate_identity", "certificate_oidc_issuer", "signed_at"} + if not is_object_with_keys(signature, signature_keys): + errors.append("signature must contain exactly asset, format, certificate_identity, certificate_oidc_issuer, and signed_at") else: - if signature["asset"] != "SHA256SUMS.sig" or signature["format"] != "openpgp": - errors.append("signature must describe SHA256SUMS.sig in openpgp format") - if not is_fingerprint(signature["key_fingerprint"]): - errors.append("signature.key_fingerprint must be exactly 40 or 64 lowercase hex") + if signature["asset"] != "SHA256SUMS.sigstore" or signature["format"] != "sigstore-bundle": + errors.append("signature must describe SHA256SUMS.sigstore as a sigstore-bundle") + if not is_certificate_identity(signature["certificate_identity"]): + errors.append("signature.certificate_identity must be an https URL") + if signature["certificate_oidc_issuer"] != OIDC_ISSUER: + errors.append(f"signature.certificate_oidc_issuer must be {OIDC_ISSUER}") if not is_strict_utc_datetime(signature["signed_at"]): errors.append("signature.signed_at must be strict UTC YYYY-MM-DDTHH:MM:SSZ") @@ -217,8 +221,11 @@ def main() -> int: return 1 signature = (schema.get("properties") or {}).get("signature", {}).get("properties", {}) or {} - if signature.get("key_fingerprint", {}).get("pattern") != EXPECTED_FINGERPRINT_PATTERN: - print("FAIL release contract check: schema key_fingerprint pattern drifted from expected constant") + if signature.get("certificate_identity", {}).get("pattern") != EXPECTED_CERTIFICATE_IDENTITY_PATTERN: + print("FAIL release contract check: schema certificate_identity pattern drifted from expected constant") + return 1 + if signature.get("certificate_oidc_issuer", {}).get("const") != EXPECTED_OIDC_ISSUER: + print("FAIL release contract check: schema certificate_oidc_issuer const drifted from expected constant") return 1 signed_at = signature.get("signed_at", {}) if signed_at.get("pattern") != EXPECTED_SIGNED_AT_PATTERN or signed_at.get("format") != EXPECTED_SIGNED_AT_FORMAT: diff --git a/tools/ci/release_seal.py b/tools/ci/release_seal.py index e284cde..d9e97f5 100755 --- a/tools/ci/release_seal.py +++ b/tools/ci/release_seal.py @@ -14,6 +14,7 @@ import tempfile from contextlib import redirect_stdout from datetime import datetime, timezone +from functools import partial from pathlib import Path from typing import Callable @@ -55,57 +56,66 @@ def git_state(root: Path, tag: str) -> tuple[str, str | None, bool | None, str | return "", None, None, None +def bundle_signed_at(signature: Path) -> str: + """Return the Rekor integrated time of a cosign bundle as strict UTC.""" + try: + bundle = json.loads(signature.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError( + "signature verification unavailable: sigstore bundle is not valid JSON" + ) from error + entry = bundle.get("logEntry") if isinstance(bundle, dict) else None + integrated = entry.get("integratedTime") if isinstance(entry, dict) else None + if not isinstance(integrated, int) or isinstance(integrated, bool) or integrated < 0: + raise ValueError( + "signature verification unavailable: sigstore bundle has no valid Rekor integrated time" + ) + try: + return datetime.fromtimestamp(integrated, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + except (OSError, OverflowError, ValueError) as error: + raise ValueError( + "signature verification unavailable: sigstore bundle has an invalid Rekor integrated time" + ) from error + + def verify_signature( sums: Path, signature: Path, *, + certificate_identity: str, + certificate_oidc_issuer: str, _which: Callable[[str], str | None] = shutil.which, _run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, -) -> tuple[str, str]: - """Return the verified fingerprint and signature time.""" - gpg = _which("gpg") - if not gpg: - raise ValueError("signature verification unavailable: gpg is not installed") +) -> tuple[str, str, str]: + """Verify a cosign keyless bundle against the checksums with cosign. + + Returns the enforced certificate identity, OIDC issuer, and the bundle's + Rekor integrated time. Fails closed when cosign is absent. + """ + cosign = _which("cosign") + if not cosign: + raise ValueError("signature verification unavailable: cosign is not installed") try: result = _run( [ - gpg, - "--batch", - "--no-auto-key-retrieve", - "--auto-key-locate", - "clear", - "--status-fd", - "1", - "--verify", + cosign, + "verify-blob", + "--bundle", str(signature), + "--certificate-identity", + certificate_identity, + "--certificate-oidc-issuer", + certificate_oidc_issuer, str(sums), ], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) except OSError as error: - raise ValueError(f"signature verification unavailable: cannot run gpg: {error}") from error + raise ValueError(f"signature verification unavailable: cannot run cosign: {error}") from error if result.returncode: - detail = result.stderr.strip() or "no VALIDSIG status" + detail = result.stderr.strip() or "cosign verify-blob failed" raise ValueError(f"signature verification unavailable: {detail}") - - valid = [line.split() for line in result.stdout.splitlines() if line.startswith("[GNUPG:] VALIDSIG ")] - if len(valid) != 1 or len(valid[0]) < 5: - raise ValueError("signature verification unavailable: expected exactly one complete VALIDSIG status") - fingerprint, creation_date, creation_epoch = valid[0][2:5] - fingerprint = fingerprint.lower() - if not contract.is_fingerprint(fingerprint): - raise ValueError("signature verification unavailable: gpg returned an invalid signer fingerprint") - try: - epoch = int(creation_epoch) - if epoch < 0: - raise ValueError - signed_at = datetime.fromtimestamp(epoch, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - parsed_date = datetime.strptime(creation_date, "%Y-%m-%d").strftime("%Y-%m-%d") - except (OSError, OverflowError, ValueError) as error: - raise ValueError("signature verification unavailable: gpg returned an invalid signature time") from error - if parsed_date != creation_date or creation_date != signed_at[:10]: - raise ValueError("signature verification unavailable: gpg signature date and epoch disagree") - return fingerprint, signed_at + return certificate_identity, certificate_oidc_issuer, bundle_signed_at(signature) def archives(tag: str) -> tuple[str, ...]: @@ -201,14 +211,16 @@ def expected_manifest( assets = [{"name": name, "url": f"{contract.RELEASE_BASE}/{tag}/{name}", "sha256": get_arch(name), "media_type": media(name)} for name in names] assets += [ {"name": "SHA256SUMS", "url": f"{contract.RELEASE_BASE}/{tag}/SHA256SUMS", "sha256": sums_d, "media_type": "text/plain"}, - {"name": "SHA256SUMS.sig", "url": f"{contract.RELEASE_BASE}/{tag}/SHA256SUMS.sig", "sha256": sig_d, "media_type": "application/pgp-signature"}, + {"name": "SHA256SUMS.sigstore", "url": f"{contract.RELEASE_BASE}/{tag}/SHA256SUMS.sigstore", "sha256": sig_d, "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json"}, ] return { "schema_version": contract.SCHEMA_VERSION, "tag": tag, "repository": contract.REPOSITORY, "source_commit": head, "lockfile_sha256": lock_digest, "immutable": True, "manifest_asset": "bran-release-manifest.json", "assets": assets, "checksums": {"asset": "SHA256SUMS", "algorithm": "sha256", "sha256": sums_d}, - "signature": {"asset": "SHA256SUMS.sig", "format": "openpgp", "key_fingerprint": signer, + "signature": {"asset": "SHA256SUMS.sigstore", "format": "sigstore-bundle", + "certificate_identity": signer, + "certificate_oidc_issuer": contract.OIDC_ISSUER, "signed_at": signed_at}, "provenance": {"format": "https://slsa.dev/provenance/v1", "predicate_type": "https://slsa.dev/provenance/v1", "source_repository": contract.REPOSITORY, "source_commit": head, "lockfile_sha256": lock_digest, @@ -216,9 +228,10 @@ def expected_manifest( } -def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, +def seal(tag: str, dist: Path, dry_run: bool, required_identity: str | None, + required_issuer: str | None, _git: Callable[[Path, str], tuple[str, str | None, bool | None, str | None]] = git_state, - _proof: Callable[[Path, Path], tuple[str, str]] = verify_signature) -> int: + _proof: Callable[[Path, Path], tuple[str, str, str]] = verify_signature) -> int: if not contract.TAG_PATTERN.fullmatch(tag): print(f"FAIL invalid tag (must be bran-vX.Y.Z): {tag}") return 1 @@ -246,13 +259,13 @@ def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, ) return 1 manifest_path = dist / "bran-release-manifest.json" - sig_path = dist / "SHA256SUMS.sig" + sig_path = dist / "SHA256SUMS.sigstore" if dry_run: if manifest_path.exists() or manifest_path.is_symlink(): print("FAIL dry-run unsigned rejects bran-release-manifest.json final-state input") return 1 if sig_path.exists() or sig_path.is_symlink(): - print("FAIL dry-run unsigned rejects SHA256SUMS.sig final-state input") + print("FAIL dry-run unsigned rejects SHA256SUMS.sigstore final-state input") return 1 try: names = require_archives(tag, dist) @@ -285,12 +298,15 @@ def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, print(f"wrote non-final evidence: {path.name}") return 0 - if not required_fingerprint or not contract.is_fingerprint(required_fingerprint.lower()): - print("FAIL real mode requires a valid --fingerprint policy value") + if not required_identity or not contract.is_certificate_identity(required_identity): + print("FAIL real mode requires a valid --certificate-identity policy value") + return 1 + if required_issuer != contract.OIDC_ISSUER: + print("FAIL real mode requires --certificate-oidc-issuer to be the exact GitHub Actions issuer") return 1 try: manifest_blob = _ensure_real_file_for_read(manifest_path, "bran-release-manifest.json") - sig_snapshot = snapshot(sig_path, "SHA256SUMS.sig") + sig_snapshot = snapshot(sig_path, "SHA256SUMS.sigstore") except ValueError as error: print(f"FAIL {error}") return 1 @@ -305,12 +321,16 @@ def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, arch_digests = {name: value[1] for name, value in archive_snapshots.items()} sums_dig_frozen = hashlib.sha256(sums_blob).hexdigest() sig_dig_frozen = sig_snapshot[1] + proof = _proof + if _proof is verify_signature: + proof = partial(verify_signature, certificate_identity=required_identity, + certificate_oidc_issuer=required_issuer) try: - signer, signed_at = _proof(sums, sig_path) - if signer != required_fingerprint.lower(): + signer, issuer, signed_at = proof(sums, sig_path) + if signer != required_identity or issuer != required_issuer: raise ValueError( - f"verified signer fingerprint mismatch: actual={signer} " - f"required={required_fingerprint.lower()}" + f"verified signer certificate mismatch: actual={signer}/{issuer} " + f"required={required_identity}/{required_issuer}" ) except (OSError, ValueError) as error: print(f"FAIL {error}") @@ -323,8 +343,8 @@ def _recheck_all() -> None: raise ValueError(f"{name} changed during verification") if _ensure_real_file_for_read(sums, "SHA256SUMS") != sums_blob: raise ValueError("SHA256SUMS changed during verification") - if snapshot(sig_path, "SHA256SUMS.sig") != sig_snapshot: - raise ValueError("SHA256SUMS.sig changed during verification") + if snapshot(sig_path, "SHA256SUMS.sigstore") != sig_snapshot: + raise ValueError("SHA256SUMS.sigstore changed during verification") if _ensure_real_file_for_read(manifest_path, "bran-release-manifest.json") != manifest_blob: raise ValueError("bran-release-manifest.json changed during verification") @@ -370,11 +390,14 @@ def _recheck_all() -> None: def test_p4_sealed_release() -> None: """The single named P4 journey covers dry-run and strict refusal paths.""" print("=== P4-SEALED-RELEASE self-test ===") - tag, fingerprint, head = "bran-v4.2.0", "0123456789abcdef0123456789abcdef01234567", "a" * 40 + tag = "bran-v4.2.0" + identity = f"https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/{tag}" + issuer = contract.OIDC_ISSUER + head = "a" * 40 signed_at = "2026-01-02T03:04:05Z" lock_digest = digest(bran_root() / "Cargo.lock") good_git = lambda _root, _tag: (head, head, False, lock_digest) - good_proof = lambda _sums, _signature: (fingerprint, signed_at) + good_proof = lambda _sums, _signature: (identity, issuer, signed_at) def expect_rejection(label: str, action: Callable[[], int]) -> None: with redirect_stdout(io.StringIO()): @@ -383,27 +406,35 @@ def expect_rejection(label: str, action: Callable[[], int]) -> None: commands: list[list[str]] = [] - def fake_gpg(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + def fake_cosign(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: commands.append(command) - status = f"[GNUPG:] VALIDSIG {fingerprint.upper()} 2026-01-02 1767323045 0 4 0 1 10 00\n" - return subprocess.CompletedProcess(command, 0, status, "") - - verified = verify_signature( - Path("SHA256SUMS"), - Path("SHA256SUMS.sig"), - _which=lambda _name: "/usr/bin/gpg", - _run=fake_gpg, - ) - assert verified == (fingerprint, signed_at) - assert commands == [[ - "/usr/bin/gpg", "--batch", "--no-auto-key-retrieve", "--auto-key-locate", "clear", - "--status-fd", "1", "--verify", "SHA256SUMS.sig", "SHA256SUMS", - ]] - try: - verify_signature(Path("SHA256SUMS"), Path("SHA256SUMS.sig"), _which=lambda _name: None) - raise AssertionError("missing gpg was accepted") - except ValueError as error: - assert "gpg is not installed" in str(error) + return subprocess.CompletedProcess(command, 0, "Verified OK\n", "") + + with tempfile.TemporaryDirectory() as tmp: + bundle_path = Path(tmp) / "SHA256SUMS.sigstore" + bundle_path.write_text(json.dumps({"logEntry": {"integratedTime": 1767323045}}), encoding="utf-8") + verified = verify_signature( + Path(tmp) / "SHA256SUMS", + bundle_path, + certificate_identity=identity, + certificate_oidc_issuer=issuer, + _which=lambda _name: "/usr/bin/cosign", + _run=fake_cosign, + ) + assert verified == (identity, issuer, signed_at) + assert commands == [[ + "/usr/bin/cosign", "verify-blob", "--bundle", str(bundle_path), + "--certificate-identity", identity, + "--certificate-oidc-issuer", issuer, + str(Path(tmp) / "SHA256SUMS"), + ]] + try: + verify_signature(Path(tmp) / "SHA256SUMS", bundle_path, + certificate_identity=identity, certificate_oidc_issuer=issuer, + _which=lambda _name: None) + raise AssertionError("missing cosign was accepted") + except ValueError as error: + assert "cosign is not installed" in str(error) with tempfile.TemporaryDirectory() as tmp: dist = Path(tmp) @@ -411,60 +442,63 @@ def fake_gpg(command: list[str], **_kwargs: object) -> subprocess.CompletedProce (dist / name).write_bytes(f"artifact-{index}".encode()) missing = dist / archives(tag)[0] missing.unlink() - expect_rejection("missing archive", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("missing archive", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) missing.write_bytes(b"artifact-0") extra = dist / f"{tag}-unsupported.tar.gz" extra.symlink_to(archives(tag)[1]) - expect_rejection("unexpected archive symlink", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("unexpected archive symlink", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) extra.unlink() - expect_rejection("wrong tag", lambda: seal(tag, dist, True, None, lambda *_: (head, "b" * 40, False, lock_digest), good_proof)) - expect_rejection("dirty tree", lambda: seal(tag, dist, True, None, lambda *_: (head, head, True, lock_digest), good_proof)) - expect_rejection("wrong lock", lambda: seal(tag, dist, True, None, lambda *_: (head, head, False, "0" * 64), good_proof)) - assert seal(tag, dist, True, None, good_git, good_proof) == 0 + expect_rejection("wrong tag", lambda: seal(tag, dist, True, None, None, lambda *_: (head, "b" * 40, False, lock_digest), good_proof)) + expect_rejection("dirty tree", lambda: seal(tag, dist, True, None, None, lambda *_: (head, head, True, lock_digest), good_proof)) + expect_rejection("wrong lock", lambda: seal(tag, dist, True, None, None, lambda *_: (head, head, False, "0" * 64), good_proof)) + assert seal(tag, dist, True, None, None, good_git, good_proof) == 0 sums = dist / "SHA256SUMS" assert sums.read_text(encoding="utf-8") == expected_sums(archives(tag), dist) evidence = dist / "bran-release-evidence.unsigned.json" first = evidence.read_bytes() - assert seal(tag, dist, True, None, good_git, good_proof) == 0 and evidence.read_bytes() == first + assert seal(tag, dist, True, None, None, good_git, good_proof) == 0 and evidence.read_bytes() == first assert not (dist / "bran-release-manifest.json").exists() - signature = dist / "SHA256SUMS.sig" + signature = dist / "SHA256SUMS.sigstore" signature.write_bytes(b"final-state signature") - expect_rejection("signature-only dry-run", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("signature-only dry-run", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) signature.unlink() sums.write_text("drift\n", encoding="utf-8") - expect_rejection("checksum drift", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("checksum drift", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) sums.write_text(expected_sums(archives(tag), dist), encoding="utf-8") signature.write_bytes(b"not accepted by the injected verifier alone") manifest_path = dist / "bran-release-manifest.json" - expect_rejection("missing manifest", lambda: seal(tag, dist, False, fingerprint, good_git, good_proof)) + expect_rejection("missing manifest", lambda: seal(tag, dist, False, identity, issuer, good_git, good_proof)) + expect_rejection("missing issuer policy", lambda: seal(tag, dist, False, identity, None, good_git, good_proof)) + expect_rejection("wrong issuer policy", lambda: seal(tag, dist, False, identity, "https://evil.example/", good_git, good_proof)) manifest = expected_manifest( - tag, head, lock_digest, archives(tag), dist, sums, signature, fingerprint, signed_at + tag, head, lock_digest, archives(tag), dist, sums, signature, identity, signed_at ) manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") - expect_rejection("unverified signature", lambda: seal(tag, dist, False, fingerprint, good_git, lambda *_: (_ for _ in ()).throw(ValueError("signature verification unavailable: no key")))) - expect_rejection("wrong signer", lambda: seal(tag, dist, False, fingerprint, good_git, lambda *_: ("f" * 40, signed_at))) + expect_rejection("unverified signature", lambda: seal(tag, dist, False, identity, issuer, good_git, lambda *_: (_ for _ in ()).throw(ValueError("signature verification unavailable: certificate identity does not match")))) + expect_rejection("wrong signer", lambda: seal(tag, dist, False, identity, issuer, good_git, lambda *_: ("https://evil.example/", issuer, signed_at))) + expect_rejection("wrong issuer", lambda: seal(tag, dist, False, identity, issuer, good_git, lambda *_: (identity, "https://evil.example/", signed_at))) original_manifest = manifest_path.read_bytes() - assert seal(tag, dist, False, fingerprint, good_git, good_proof) == 0 + assert seal(tag, dist, False, identity, issuer, good_git, good_proof) == 0 assert manifest_path.read_bytes() == original_manifest original_sums = sums.read_bytes() - def mutating_proof(_sums: Path, _signature: Path) -> tuple[str, str]: + def mutating_proof(_sums: Path, _signature: Path) -> tuple[str, str, str]: sums.write_bytes(original_sums + b"drift") - return fingerprint, signed_at - expect_rejection("mutated supporting asset", lambda: seal(tag, dist, False, fingerprint, good_git, mutating_proof)) + return identity, issuer, signed_at + expect_rejection("mutated supporting asset", lambda: seal(tag, dist, False, identity, issuer, good_git, mutating_proof)) sums.write_bytes(original_sums) symlink_archive = dist / archives(tag)[0] saved_archive = dist / "saved-archive" symlink_archive.rename(saved_archive) symlink_archive.symlink_to(saved_archive.name) - expect_rejection("symlink archive", lambda: seal(tag, dist, False, fingerprint, good_git, good_proof)) + expect_rejection("symlink archive", lambda: seal(tag, dist, False, identity, issuer, good_git, good_proof)) symlink_archive.unlink() saved_archive.rename(symlink_archive) - expect_rejection("dry-run final-state inputs", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("dry-run final-state inputs", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) assert manifest_path.read_bytes() == original_manifest manifest["assets"][0]["sha256"] = "0" * 64 manifest_path.write_text(json.dumps(manifest), encoding="utf-8") tampered_manifest = manifest_path.read_bytes() - expect_rejection("tampered manifest", lambda: seal(tag, dist, False, fingerprint, good_git, good_proof)) + expect_rejection("tampered manifest", lambda: seal(tag, dist, False, identity, issuer, good_git, good_proof)) assert manifest_path.read_bytes() == tampered_manifest print("=== P4-SEALED-RELEASE self-test PASS ===") @@ -481,9 +515,11 @@ def main() -> int: parser.add_argument("--tag", required=True) parser.add_argument("--dist", required=True, type=Path) parser.add_argument("--dry-run-unsigned", action="store_true") - parser.add_argument("--fingerprint") + parser.add_argument("--certificate-identity") + parser.add_argument("--certificate-oidc-issuer") args = parser.parse_args() - return seal(args.tag, args.dist.resolve(), args.dry_run_unsigned, args.fingerprint) + return seal(args.tag, args.dist.resolve(), args.dry_run_unsigned, + args.certificate_identity, args.certificate_oidc_issuer) if __name__ == "__main__": From 2e53d883894efb8e8fa7df1b64cb3d9aaa681c0f Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 10:30:58 -0500 Subject: [PATCH 2/4] Public export snapshot from bran-dev 5e6f0f5 Fixes release build portability on Windows and macOS runners. --- .bran-export.json | 6 +++--- .github/workflows/release.yml | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index 532a217..bbe265b 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "94b66ce2ea63b434a7571ef614121ecb5ee963bd", + "source_commit": "5e6f0f5379010dbbacdfcba8f61dc69c832c7883", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 6636, - "sha256": "584fb7bb2ceaab988be2500a7ab8598ed6ab073ce410546e7808ad5d5e3749a7" + "bytes": 7058, + "sha256": "53a63d2609b1ec1431894899f57dfc42256d211b163babc8a070fdde73acc661" }, { "path": ".gitignore", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ead78c..bb59822 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,15 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + # build-release.sh packages archives with python3. Windows runners expose + # Python as `python`, not `python3`, so pin an interpreter explicitly. + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: Build release archive + # build-release.sh is /bin/sh. The default shell on windows-latest is + # PowerShell, which cannot execute it; `bash` resolves to Git Bash there. + shell: bash run: ./tools/ci/build-release.sh --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist - uses: actions/upload-artifact@v4 with: From 7088bf3aafe352fe49eeed4985395b30348042dd Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 11:29:44 -0500 Subject: [PATCH 3/4] Public export snapshot from bran-dev 73481c7 Rust xtask release packager, publish step, and pre-publish manifest validation. --- .bran-export.json | 36 +++-- .github/workflows/release.yml | 48 ++++-- Cargo.lock | 95 ++++++++++++ Cargo.toml | 2 +- tools/ci/build-release.sh | 64 +------- xtask/Cargo.toml | 10 ++ xtask/src/archive.rs | 285 ++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 168 ++++++++++++++++++++ 8 files changed, 632 insertions(+), 76 deletions(-) create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/archive.rs create mode 100644 xtask/src/main.rs diff --git a/.bran-export.json b/.bran-export.json index bbe265b..d12a61d 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "5e6f0f5379010dbbacdfcba8f61dc69c832c7883", + "source_commit": "73481c7ba1770624dc89129a152b295decad5808", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 7058, - "sha256": "53a63d2609b1ec1431894899f57dfc42256d211b163babc8a070fdde73acc661" + "bytes": 8251, + "sha256": "86796ba128a0ba82473dea045ff26c48e67a7531125d44b034e39415d48749e8" }, { "path": ".gitignore", @@ -38,14 +38,14 @@ { "path": "Cargo.lock", "mode": "100644", - "bytes": 6486, - "sha256": "7305ad8cdf4a72f852943c840893720a9781c1445ed955d5fb9b019fb8d680a1" + "bytes": 8934, + "sha256": "84d67f2d5d5ec36a738e56400dab679d139cf7bb15b1012b3f9d9a48606a1602" }, { "path": "Cargo.toml", "mode": "100644", - "bytes": 96, - "sha256": "3322881f7489b12e80a2b7a727cec3d2173f9615f03dc6fbb5a28490037f6da3" + "bytes": 105, + "sha256": "b8ff81cee89b842d6970d0ecb1670c77dd98f28ddfacacc5564d430570768372" }, { "path": "LICENSE", @@ -680,8 +680,8 @@ { "path": "tools/ci/build-release.sh", "mode": "100755", - "bytes": 4290, - "sha256": "97b70095b0a16e8eb186fb24d2cfbd3bce14063b331c1b746e4582f6c74a1577" + "bytes": 2854, + "sha256": "f84343135189fb33fcbcb41e55792a87f63731bc9e2a710fc0e0adf4636e0204" }, { "path": "tools/ci/check.sh", @@ -730,6 +730,24 @@ "mode": "100644", "bytes": 33505, "sha256": "ff60cd69b4a5e7994004095441491fe24b3635159676ea4133955006b9187f1c" + }, + { + "path": "xtask/Cargo.toml", + "mode": "100644", + "bytes": 264, + "sha256": "a4786c44a9e41ef709353d292d586628775a6007115bfa33d64b68aa57b5fd75" + }, + { + "path": "xtask/src/archive.rs", + "mode": "100644", + "bytes": 11299, + "sha256": "b62ebac302bd3d718a29d6c22d2e7440780372ead11b313e8d7bf1a0177025b4" + }, + { + "path": "xtask/src/main.rs", + "mode": "100644", + "bytes": 5272, + "sha256": "142d55253c8c8912015a0c70a35675adc7dc899ffe5a998809a695b7e70cf169" } ] } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb59822..35f2fba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,16 +32,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - # build-release.sh packages archives with python3. Windows runners expose - # Python as `python`, not `python3`, so pin an interpreter explicitly. - - uses: actions/setup-python@v5 - with: - python-version: "3.12" + # The xtask packages archives with only cargo; no python interpreter or + # /bin/sh shim is needed on any runner. - name: Build release archive - # build-release.sh is /bin/sh. The default shell on windows-latest is - # PowerShell, which cannot execute it; `bash` resolves to Git Bash there. - shell: bash - run: ./tools/ci/build-release.sh --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist + run: cargo run --locked -p xtask -- package --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist - uses: actions/upload-artifact@v4 with: name: artifact-${{ matrix.target }} @@ -180,7 +174,43 @@ jobs: json.dumps(manifest, indent=2) + "\n", encoding="utf-8" ) PY + # Validate the generated manifest against the shipped release contract + # before anything is published. A manifest that violates its own contract + # must never reach a release. + - name: Verify the generated manifest satisfies the release contract + run: | + python3 - <<'PY' + import json + import pathlib + import sys + + sys.path.insert(0, "tools/ci") + import release_contract_check as contract + + manifest = json.loads( + pathlib.Path("dist/bran-release-manifest.json").read_text(encoding="utf-8") + ) + errors = contract.validate_manifest(manifest) + if errors: + print("FAIL generated manifest violates the release contract") + for error in errors: + print(f" {error}") + raise SystemExit(1) + print("PASS generated manifest satisfies the release contract") + PY - uses: actions/upload-artifact@v4 with: name: release-files path: dist/ + # Publish the release. Without this the assets exist only as workflow + # artifacts and every releases/download URL in the manifest would 404. + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + gh release create "$TAG" \ + --repo "${{ github.repository }}" \ + --title "$TAG" \ + --notes "Release $TAG. Verify with cosign: see the Releases section of the README." \ + dist/* diff --git a/Cargo.lock b/Cargo.lock index a1f60c1..65fb6db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "bitflags" version = "2.13.1" @@ -31,6 +37,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -66,6 +81,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -76,6 +97,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "libc" version = "0.2.186" @@ -109,6 +156,22 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -203,12 +266,24 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -251,3 +326,23 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "flate2", + "zip", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] diff --git a/Cargo.toml b/Cargo.toml index 2a06de8..31f367e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/bran-cli", "crates/bran-core", "crates/bran-tui"] +members = ["crates/bran-cli", "crates/bran-core", "crates/bran-tui", "xtask"] resolver = "2" diff --git a/tools/ci/build-release.sh b/tools/ci/build-release.sh index 6fd0166..ec70c2c 100755 --- a/tools/ci/build-release.sh +++ b/tools/ci/build-release.sh @@ -1,6 +1,8 @@ #!/bin/sh # build-release.sh: plan + per-target package for exact 5 cross artifacts. -# --plan validates names only (non-mutating). Build uses --locked, fails on missing target. +# --plan validates names only (non-mutating). The build and packaging are +# delegated to the Rust xtask (cargo run -p xtask -- package), so the release +# path needs only cargo. Build uses --locked, fails on missing target. set -eu usage() { @@ -93,6 +95,7 @@ if $plan_mode; then exit 0 fi +# Reject unknown targets before delegating (artifact_name exits non-zero). name=$(artifact_name "$target") script_dir=$(CDPATH="" cd "$(dirname "$0")" && pwd -P) bran_root=$(CDPATH="" cd "$script_dir/../.." && pwd -P) @@ -102,60 +105,7 @@ if [ ! -f "$bran_root/Cargo.lock" ]; then exit 1 fi -mkdir -p "$dist" -printf 'BUILD target=%s tag=%s artifact=%s\n' "$target" "$tag" "$name" - +# The xtask performs the build and the deterministic packaging; cargo is the +# only runtime the release path needs. cd "$bran_root" -cargo build --release --locked --target "$target" --bin bran - -case "$target" in - "$WX86") bin_path="target/$target/release/bran.exe" ;; - *) bin_path="target/$target/release/bran" ;; -esac - -[ -f "$bin_path" ] || { printf 'FAIL binary not found: %s\n' "$bin_path" >&2; exit 1; } - -out="$dist/$name" -case "$target" in - "$WX86") member=bran.exe; package=zip ;; - *) member=bran; package=tar.gz ;; -esac - -python3 - "$bin_path" "$out" "$member" "$package" <<'PY' -import gzip -import io -import os -import sys -import tarfile -import zipfile - -binary, output, member, package = sys.argv[1:] -with open(binary, "rb") as source: - data = source.read() - -if package == "tar.gz": - with open(output, "wb") as raw: - with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: - with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: - info = tarfile.TarInfo(member) - info.type = tarfile.REGTYPE - info.mode = 0o755 - info.uid = info.gid = info.mtime = 0 - info.uname = info.gname = "" - info.size = len(data) - archive.addfile(info, io.BytesIO(data)) -elif package == "zip": - info = zipfile.ZipInfo(member, (1980, 1, 1, 0, 0, 0)) - info.create_system = 3 - info.external_attr = 0o100755 << 16 - info.compress_type = zipfile.ZIP_STORED - info.extra = info.comment = b"" - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: - archive.comment = b"" - archive.writestr(info, data) -else: - raise RuntimeError("unsupported package format") -PY -[ -f "$out" ] || { printf 'FAIL no archive: %s\n' "$out" >&2; exit 1; } -printf 'CREATED %s\n' "$out" -exit 0 +exec cargo run --locked -p xtask -- package --target "$target" --tag "$tag" --dist "$dist" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..e6c96bc --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] } +zip = { version = "=8.6.0", default-features = false } diff --git a/xtask/src/archive.rs b/xtask/src/archive.rs new file mode 100644 index 0000000..733d17d --- /dev/null +++ b/xtask/src/archive.rs @@ -0,0 +1,285 @@ +//! Deterministic archive writing. +//! +//! These writers are hand-rolled so every byte is under our control. The +//! properties match what `tools/ci/build-release.sh` used to produce with +//! python3's gzip/tarfile/zipfile. Byte identity with that output is not +//! required; determinism is: the same binary always yields the same archive +//! bytes, on every host. + +use std::fs; +use std::io::{self, Read, Seek, Write}; +use std::path::Path; + +/// Writes `data` as member `member` inside a gzip-compressed USTAR archive. +/// +/// Gzip: no filename, no extra fields, mtime 0. Tar: USTAR, single regular +/// file member with mode 0o755, uid 0, gid 0, mtime 0, empty uname/gname. +pub fn write_tar_gz(path: &Path, member: &str, data: &[u8]) -> io::Result<()> { + write_tar_gz_to(io::BufWriter::new(fs::File::create(path)?), member, data).map(|_| ()) +} + +fn write_tar_gz_to(mut writer: W, member: &str, data: &[u8]) -> io::Result { + let encoder = flate2::GzBuilder::new() + .mtime(0) + .write(&mut writer, flate2::Compression::default()); + let mut encoder = io::BufWriter::new(encoder); + write_ustar(&mut encoder, member, data)?; + encoder.flush()?; + let encoder = encoder.into_inner().map_err(io::Error::from)?; + encoder.finish()?; + Ok(writer) +} + +const TAR_BLOCK_SIZE: usize = 512; + +/// Writes a single-member USTAR archive: one 512-byte header, the file data +/// padded to the block boundary, and two zero blocks ending the archive. +fn write_ustar(writer: &mut W, member: &str, data: &[u8]) -> io::Result<()> { + if member.len() > 100 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("tar member name too long: {member}"), + )); + } + let mut header = [0u8; TAR_BLOCK_SIZE]; + header[..member.len()].copy_from_slice(member.as_bytes()); + write_octal(&mut header[100..108], 0o755)?; // mode + write_octal(&mut header[108..116], 0)?; // uid + write_octal(&mut header[116..124], 0)?; // gid + write_octal(&mut header[124..136], data.len() as u64)?; + write_octal(&mut header[136..148], 0)?; // mtime + header[156] = b'0'; // regular file + header[257..263].copy_from_slice(b"ustar\0"); + header[263..265].copy_from_slice(b"00"); + // uname, gname, devmajor, devminor and prefix stay zeroed. + // Checksum: the header with the checksum field treated as spaces. + header[148..156].fill(b' '); + let sum: u64 = header.iter().map(|&byte| u64::from(byte)).sum(); + write_octal(&mut header[149..156], sum)?; + writer.write_all(&header)?; + writer.write_all(data)?; + let padding = (TAR_BLOCK_SIZE - (data.len() % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE; + if padding > 0 { + writer.write_all(&[0u8; TAR_BLOCK_SIZE][..padding])?; + } + writer.write_all(&[0u8; TAR_BLOCK_SIZE * 2])?; // end-of-archive marker + Ok(()) +} + +/// Writes a leading-zero octal number NUL-terminated into a fixed-width +/// field, the USTAR convention (e.g. mode 0o755 -> "0000755\0"). +fn write_octal(field: &mut [u8], value: u64) -> io::Result<()> { + let width = field.len() - 1; + let mut remaining = value; + for slot in field[..width].iter_mut().rev() { + *slot = b'0' + (remaining % 8) as u8; + remaining /= 8; + } + if remaining != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "value {value:#o} does not fit in {} octal digits", + width - 1 + ), + )); + } + field[width] = 0; + Ok(()) +} + +/// Writes `data` as member `member` inside a ZIP archive, STORED +/// (uncompressed), with no extra fields, no comments and no timestamps +/// beyond the DOS epoch 1980-01-01 00:00:00. +pub fn write_zip(path: &Path, member: &str, data: &[u8]) -> io::Result<()> { + write_zip_to(fs::File::create(path)?, member, data).map(|_| ()) +} + +fn write_zip_to(mut writer: W, member: &str, data: &[u8]) -> io::Result { + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .unix_permissions(0o755) + .system(zip::System::Unix); + let mut archive = zip::ZipWriter::new(&mut writer); + archive.start_file(member, options)?; + archive.write_all(data)?; + archive.finish()?; + patch_zip_external_attr(&mut writer)?; + Ok(writer) +} + +/// Patches the central directory's external-attribute field to +/// `0o100755 << 16`. +/// +/// `unix_permissions` masks mode bits to 0o777, dropping the S_IFREG bit of +/// the 0o100755 mode the release contract specifies. The central directory +/// offset is read from the end-of-central-directory record, so the patch does +/// not depend on any particular header layout. +fn patch_zip_external_attr(writer: &mut W) -> io::Result<()> { + let end = writer.stream_position()?; + if end < 22 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zip is too small to hold an end-of-central-directory record", + )); + } + writer.seek(io::SeekFrom::Start(end - 22))?; + let mut eocd = [0u8; 22]; + writer.read_exact(&mut eocd)?; + if eocd[..4] != [0x50, 0x4b, 0x05, 0x06] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "missing end-of-central-directory record", + )); + } + let central_offset = u64::from(u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]])); + // A central directory entry is: 4-byte signature, then 34 bytes of fixed + // fields, then the external-attribute field (APPNOTE 4.3.12). + writer.seek(io::SeekFrom::Start(central_offset + 4 + 34))?; + writer.write_all(&(0o100755u32 << 16).to_le_bytes())?; + writer.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A payload spanning several tar blocks with varied bytes. + fn payload() -> Vec { + let mut bytes = Vec::with_capacity(3000); + for i in 0..3000u32 { + bytes.push(((i * 31 + (i >> 3)) & 0xff) as u8); + } + bytes + } + + #[test] + fn tar_gz_is_deterministic() { + let data = payload(); + let first = write_tar_gz_to(Vec::new(), "bran", &data).unwrap(); + let second = write_tar_gz_to(Vec::new(), "bran", &data).unwrap(); + assert_eq!(first, second); + } + + #[test] + fn zip_is_deterministic() { + let data = payload(); + let first = write_zip_to(io::Cursor::new(Vec::new()), "bran.exe", &data) + .unwrap() + .into_inner(); + let second = write_zip_to(io::Cursor::new(Vec::new()), "bran.exe", &data) + .unwrap() + .into_inner(); + assert_eq!(first, second); + } + + #[test] + fn tar_gz_has_expected_member_metadata() { + let data = payload(); + let gzip = write_tar_gz_to(Vec::new(), "bran", &data).unwrap(); + // gzip header: magic, deflate, no flags (no filename/extra/comment), + // mtime 0. + assert_eq!(&gzip[..2], &[0x1f, 0x8b]); + assert_eq!(gzip[2], 8); + assert_eq!(gzip[3], 0); + assert_eq!(&gzip[4..8], &[0, 0, 0, 0]); + + let mut tar = Vec::new(); + flate2::read::GzDecoder::new(&gzip[..]) + .read_to_end(&mut tar) + .unwrap(); + assert_eq!(tar.len(), 512 + padded_len(data.len()) + 1024); + + let header = &tar[..512]; + assert_eq!(header_name(header), "bran"); + assert_eq!(parse_octal(&header[100..108]), 0o755); // mode + assert_eq!(parse_octal(&header[108..116]), 0); // uid + assert_eq!(parse_octal(&header[116..124]), 0); // gid + assert_eq!(parse_octal(&header[124..136]), data.len() as u64); + assert_eq!(parse_octal(&header[136..148]), 0); // mtime + assert_eq!(header[156], b'0'); // regular file + assert_eq!(&header[257..263], b"ustar\0"); + assert_eq!(&header[263..265], b"00"); + assert!(header[265..297].iter().all(|&b| b == 0)); // uname + assert!(header[297..329].iter().all(|&b| b == 0)); // gname + assert_eq!(&tar[512..512 + data.len()], &data[..]); + // checksum: header with the checksum field treated as spaces. + let mut sum: u64 = 0; + for (i, &byte) in header.iter().enumerate() { + sum += if (148..156).contains(&i) { + 0x20 + } else { + u64::from(byte) + }; + } + assert_eq!(parse_octal(&header[148..156]), sum); + // padding and the end-of-archive marker are zero. + assert!(tar[512 + data.len()..].iter().all(|&b| b == 0)); + } + + #[test] + fn zip_has_expected_entry_properties() { + let data = payload(); + let archive = write_zip_to(io::Cursor::new(Vec::new()), "bran.exe", &data) + .unwrap() + .into_inner(); + let (offset, count) = central_directory(&archive); + assert_eq!(count, 1); + let entry = &archive[offset..]; + assert_eq!(&entry[..4], &[0x50, 0x4b, 0x01, 0x02]); + let version_made_by = u16::from_le_bytes([entry[4], entry[5]]); + assert_eq!(version_made_by >> 8, 3); // create_system: Unix + let method = u16::from_le_bytes([entry[10], entry[11]]); + assert_eq!(method, 0); // STORED + let time = u16::from_le_bytes([entry[12], entry[13]]); + let date = u16::from_le_bytes([entry[14], entry[15]]); + assert_eq!(time, 0); + assert_eq!(date, 0x21); // DOS epoch: 1980-01-01 00:00:00 + let size = u32::from_le_bytes([entry[24], entry[25], entry[26], entry[27]]); + assert_eq!(size, data.len() as u32); + let name_len = u16::from_le_bytes([entry[28], entry[29]]); + let extra_len = u16::from_le_bytes([entry[30], entry[31]]); + let comment_len = u16::from_le_bytes([entry[32], entry[33]]); + assert_eq!(&entry[46..46 + name_len as usize], b"bran.exe"); + assert_eq!(extra_len, 0); + assert_eq!(comment_len, 0); + let external = u32::from_le_bytes([entry[38], entry[39], entry[40], entry[41]]); + assert_eq!(external, 0o100755 << 16); + // the archive comment is empty + assert_eq!(&archive[archive.len() - 2..], &[0, 0]); + } + + fn padded_len(n: usize) -> usize { + n.div_ceil(512) * 512 + } + + fn header_name(header: &[u8]) -> String { + let end = header[..100].iter().position(|&b| b == 0).unwrap_or(100); + String::from_utf8(header[..end].to_vec()).unwrap() + } + + fn parse_octal(field: &[u8]) -> u64 { + let s: String = field + .iter() + .map(|&b| b as char) + .skip_while(|&c| c == ' ') + .take_while(|&c| c != '\0' && c != ' ') + .collect(); + u64::from_str_radix(&s, 8).unwrap() + } + + /// Returns the offset and entry count of the central directory. + fn central_directory(archive: &[u8]) -> (usize, usize) { + let eocd = archive.len() - 22; + assert_eq!(&archive[eocd..eocd + 4], &[0x50, 0x4b, 0x05, 0x06]); + let count = u16::from_le_bytes([archive[eocd + 10], archive[eocd + 11]]) as usize; + let offset = u32::from_le_bytes([ + archive[eocd + 16], + archive[eocd + 17], + archive[eocd + 18], + archive[eocd + 19], + ]) as usize; + (offset, count) + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..dc9be74 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,168 @@ +//! BRAN release packager (xtask). +//! +//! Replaces the shell+python packaging that `tools/ci/build-release.sh` used +//! to inline, so the release path needs only cargo: +//! +//! ```text +//! cargo run -p xtask -- package --target --tag --dist +//! ``` +//! +//! Builds the `bran` binary with `--locked` and writes the deterministic +//! archive described in `tools/ci/build-release.sh` and `docs/plans/`. + +mod archive; + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +/// The exact five release targets. Anything else is rejected. +const TARGETS: [&str; 5] = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +]; + +const WINDOWS_TARGET: &str = "x86_64-pc-windows-msvc"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("FAIL {message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("package") => package(args), + Some(other) => Err(format!("unknown command: {other}")), + None => Err(usage()), + } +} + +fn usage() -> String { + "usage: cargo run -p xtask -- package --target TRIPLE --tag TAG --dist DIR".to_string() +} + +fn package(args: impl Iterator) -> Result<(), String> { + let mut target = None; + let mut tag = None; + let mut dist = None; + let mut args = args.into_iter(); + while let Some(option) = args.next() { + let value = match option.as_str() { + "--target" | "--tag" | "--dist" => args + .next() + .ok_or_else(|| format!("missing value for {option}"))?, + _ => return Err(format!("unknown option: {option}")), + }; + match option.as_str() { + "--target" => target = Some(value), + "--tag" => tag = Some(value), + _ => dist = Some(value), + } + } + let target = target.ok_or("missing --target TRIPLE")?; + let tag = tag.ok_or("missing --tag TAG")?; + let dist = dist.ok_or("missing --dist DIR")?; + + if !TARGETS.contains(&target.as_str()) { + return Err(format!( + "unknown target: {target} (expected one of: {})", + TARGETS.join(", ") + )); + } + validate_tag(&tag)?; + + let archive_name = archive_name(&tag, &target); + println!("BUILD target={target} tag={tag} artifact={archive_name}"); + + let root = workspace_root()?; + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--locked", + "--target", + target.as_str(), + "--bin", + "bran", + ]) + .current_dir(&root) + .status() + .map_err(|error| format!("failed to run cargo build: {error}"))?; + if !status.success() { + return Err("cargo build failed".to_string()); + } + + let binary_name = if target == WINDOWS_TARGET { + "bran.exe" + } else { + "bran" + }; + let binary_path = root + .join("target") + .join(&target) + .join("release") + .join(binary_name); + let binary = fs::read(&binary_path) + .map_err(|error| format!("binary not found at {}: {error}", binary_path.display()))?; + + let dist = PathBuf::from(dist); + let dist = if dist.is_absolute() { + dist + } else { + env::current_dir() + .map_err(|error| format!("cannot resolve --dist: {error}"))? + .join(dist) + }; + fs::create_dir_all(&dist).map_err(|error| format!("cannot create --dist dir: {error}"))?; + let out_path = dist.join(&archive_name); + let result = if target == WINDOWS_TARGET { + archive::write_zip(&out_path, binary_name, &binary) + } else { + archive::write_tar_gz(&out_path, binary_name, &binary) + }; + result.map_err(|error| format!("failed to write {}: {error}", out_path.display()))?; + println!("CREATED {}", out_path.display()); + Ok(()) +} + +/// Rejects tags that could not be a safe single path component: the tag is +/// part of the artifact filename. +fn validate_tag(tag: &str) -> Result<(), String> { + if tag.is_empty() || tag == "." || tag == ".." || tag.contains('/') || tag.contains('\\') { + return Err(format!("invalid tag: {tag:?}")); + } + Ok(()) +} + +/// The workspace root is the parent of the xtask crate; cargo sets +/// CARGO_MANIFEST_DIR whenever the xtask is built, so this works no matter +/// which directory the caller runs `cargo run -p xtask` from. +fn workspace_root() -> Result { + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .ok_or("CARGO_MANIFEST_DIR is not set; run via `cargo run -p xtask`")?; + let manifest_dir = PathBuf::from(manifest_dir); + manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| { + format!( + "CARGO_MANIFEST_DIR has no parent: {}", + manifest_dir.display() + ) + }) +} + +fn archive_name(tag: &str, target: &str) -> String { + if target == WINDOWS_TARGET { + format!("{tag}-{target}.zip") + } else { + format!("{tag}-{target}.tar.gz") + } +} From add3ed1592aeae3b643b10dafe1508a5c0460935 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 11:35:58 -0500 Subject: [PATCH 4/4] Public export snapshot from bran-dev 32f3792 Pin third-party release actions to commit SHAs. --- .bran-export.json | 6 +++--- .github/workflows/release.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index d12a61d..562cf89 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "73481c7ba1770624dc89129a152b295decad5808", + "source_commit": "32f37928f77b21b1d1a55a22862c91f61fce7ed0", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 8251, - "sha256": "86796ba128a0ba82473dea045ff26c48e67a7531125d44b034e39415d48749e8" + "bytes": 8337, + "sha256": "ac6713baf1765339026fd05e1161e07eb1b943c21a6553daf77ad7081a73a1de" }, { "path": ".gitignore", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35f2fba..17db837 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: runner: windows-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: ${{ matrix.target }} # The xtask packages archives with only cargo; no python interpreter or @@ -81,7 +81,7 @@ jobs: lines.append(f"{digest} {name}") (dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") PY - - uses: sigstore/cosign-installer@v3 + - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - name: Sign SHA256SUMS with cosign keyless run: cosign sign-blob --yes --bundle dist/SHA256SUMS.sigstore dist/SHA256SUMS - name: Emit bran-release-manifest.json