bao-wrapper is a CI-agnostic process wrapper for OpenBao / HashiCorp Vault.
It fetches secrets at runtime, injects them into the child process's environment (or as temporary files), and masks registered values in captured stdout and stderr — all without touching the CI runner's own environment.
- Key features
- Installation
- Quick Start
- Usage
- Template engine
- Examples
- Architecture
- Security notes
- Development
| Feature | Details |
|---|---|
| Zero external dependencies | Uses only the Go standard library (net/http, encoding/json, …) |
| Authentication | Direct token, JWT/OIDC, or AppRole, with ownership-aware token cleanup |
| Streaming log masking | Replaces registered plaintext values of at least four bytes with [MASKED], including matches split across writes |
env and file injection |
Secrets are exposed as env vars or temp files (0600 on Unix; inherited ACLs on Windows); sensitive config vars are stripped from the child process |
| Template engine | Go text/template rendering with in-template {{ secret "..." }} lookups and selective masking |
| Automatic retry | Secret reads and revocation: up to 3 retries on network errors or HTTP 502/503/504, with exponential backoff and jitter; logins are not retried |
| Direct execution | Launches the supplied command without implicit shell expansion |
| Cross-platform | Linux, macOS (amd64/arm64), Windows (amd64) |
This README describes the current source. Published releases may differ; build from source if a documented feature is not yet released. The README examples are updated automatically but may lag behind new releases. See GitHub Releases for the latest version.
# Linux amd64; pin the release version and its verified checksum.
BAO_WRAPPER_VERSION=v0.7.0
BAO_WRAPPER_SHA256='a5501404e717b4fd39b5597c5f390019c6656543112f5079c2fbac0368fbff17'
curl -fsSLO "https://github.com/philhartung/bao-wrapper/releases/download/${BAO_WRAPPER_VERSION}/bao-wrapper-linux-amd64" &&
printf '%s %s\n' "$BAO_WRAPPER_SHA256" bao-wrapper-linux-amd64 | sha256sum --check --strict &&
install -m 0755 bao-wrapper-linux-amd64 bao-wrapperAvailable release assets:
| Platform | File |
|---|---|
| Linux amd64 | bao-wrapper-linux-amd64 |
| Linux arm64 | bao-wrapper-linux-arm64 |
| macOS amd64 | bao-wrapper-darwin-amd64 |
| macOS arm64 (M-series) | bao-wrapper-darwin-arm64 |
| Windows amd64 | bao-wrapper-windows-amd64.exe |
The current release workflow publishes an SPDX 2.3 JSON SBOM for each binary as <binary-name>.spdx.json, including bao-wrapper-windows-amd64.exe.spdx.json for Windows. Syft generates each SBOM from the compiled binary, recording its Go modules and standard library version. The pinned SBOM action also pins its default Syft version; Renovate updates the action.
SHA256SUMS covers both binaries and SBOMs. GitHub build-provenance attestations cover the manifest, binaries and SBOM files; a separate SBOM attestation binds each SBOM to its binary's digest. Publication requires successful tests, SBOM generation and attestations. Older releases may not include these assets.
For releases containing SHA256SUMS, download the manifest and selected binary from an explicit version, authenticate both with the GitHub CLI, and only then install the binary:
BAO_WRAPPER_VERSION=v0.7.0
BAO_WRAPPER_SHA256='a5501404e717b4fd39b5597c5f390019c6656543112f5079c2fbac0368fbff17'
curl -fsSLO "https://github.com/philhartung/bao-wrapper/releases/download/${BAO_WRAPPER_VERSION}/SHA256SUMS" &&
curl -fsSLO "https://github.com/philhartung/bao-wrapper/releases/download/${BAO_WRAPPER_VERSION}/bao-wrapper-linux-amd64" &&
gh attestation verify SHA256SUMS --repo philhartung/bao-wrapper &&
gh attestation verify bao-wrapper-linux-amd64 --repo philhartung/bao-wrapper &&
sha256sum --check --ignore-missing --strict SHA256SUMS &&
printf '%s %s\n' "$BAO_WRAPPER_SHA256" bao-wrapper-linux-amd64 | sha256sum --check --strict &&
install -m 0755 bao-wrapper-linux-amd64 bao-wrapperThe attestation verifies the artifact's signed provenance; SHA256SUMS makes the release's complete digest set easy to inspect and use in systems that require a pinned checksum.
To download and verify the selected binary's SBOM, using the same release version:
curl -fsSLO "https://github.com/philhartung/bao-wrapper/releases/download/${BAO_WRAPPER_VERSION}/bao-wrapper-linux-amd64.spdx.json" &&
gh attestation verify bao-wrapper-linux-amd64.spdx.json --repo philhartung/bao-wrapper &&
sha256sum --check --ignore-missing --strict SHA256SUMS &&
gh attestation verify bao-wrapper-linux-amd64 --repo philhartung/bao-wrapper \
--predicate-type https://spdx.dev/Document/v2.3git clone https://github.com/philhartung/bao-wrapper.git
cd bao-wrapper
CGO_ENABLED=0 go build -o bao-wrapper .- Download the binary (see Installation).
- Set your Vault address and authentication variables.
- Prefix every secret you need with
SECRET_(or a custom prefix via--secret-prefix) and describe where to fetch it. - Wrap your command with
bao-wrapper run --:
export BAO_ADDR="https://vault.example.com"
export BAO_JWT_ROLE="my-role"
export BAO_JWT_TOKEN="<jwt>" # or use AppRole / GitHub Actions OIDC
export SECRET_DB_PASS="kv://password@kv/myapp/db"
bao-wrapper run -- ./my-appThe child process receives DB_PASS=<actual value>. Exact plaintext matches of registered values at least four bytes long are masked in captured stdout and stderr. See Security notes for the limits of masking and cleanup.
bao-wrapper run [options] -- <command> [args...]
| Flag | Default | Description |
|---|---|---|
--auth-path <path> |
jwt for JWT; approle for AppRole |
Authentication mount used by the selected login method. Accepts a mount name such as gitlab or an auth-relative path such as auth/gitlab; /login is appended automatically. Can also be set via BAO_AUTH_PATH (fallback: VAULT_AUTH_PATH); the CLI flag takes priority. |
--revoke-token <policy> |
auto |
Token cleanup policy: auto revokes login-issued tokens and preserves supplied tokens; always revokes either; never preserves either. Also accepts --revoke-token=<policy>. Overrides BAO_REVOKE_TOKEN. |
--secret-prefix <prefix> |
SECRET_ |
Prefix used to identify secret environment variables. Variables whose name starts with this prefix are parsed as secret references; the prefix is stripped before injecting the value into the child process. Can also be set via the BAO_SECRET_PREFIX environment variable; the CLI flag takes priority. |
| Variable | Fallback | Required | Description |
|---|---|---|---|
BAO_ADDR |
VAULT_ADDR |
yes | OpenBao/Vault server URL (e.g. https://openbao.example.com) |
BAO_NAMESPACE |
VAULT_NAMESPACE |
no | Namespace |
BAO_TOKEN |
VAULT_TOKEN |
no | Direct client token; takes priority over all login methods. Use when a token is already available (e.g. local dev, pre-issued tokens). This token is borrowed and preserved by default, including after failures and signals. Use --revoke-token=always to opt into revoking a disposable token. |
BAO_AUTH_PATH |
VAULT_AUTH_PATH |
no | Authentication mount used for JWT or AppRole login. Accepts gitlab and auth/gitlab forms. Defaults to jwt for JWT and approle for AppRole; overridden by --auth-path. |
BAO_JWT_ROLE |
VAULT_JWT_ROLE |
no | JWT auth role (used when BAO_TOKEN is not set; skips JWT login when omitted) |
BAO_JWT_TOKEN |
VAULT_JWT_TOKEN |
no | JWT token for authentication (auto-detected from GitHub Actions OIDC when unset) |
BAO_APP_ID |
VAULT_APP_ID |
no | AppRole role ID (used when BAO_TOKEN and BAO_JWT_ROLE are not set) |
BAO_APP_SECRET |
VAULT_APP_SECRET |
no | AppRole secret ID (used together with BAO_APP_ID) |
BAO_CACERT |
VAULT_CACERT |
no | Path to a PEM-encoded CA certificate file (for self-signed or corporate CA) |
BAO_MAX_RESPONSE_BYTES |
VAULT_MAX_RESPONSE_BYTES |
no | Maximum OpenBao/Vault response body size in bytes (default: 33554432 / 32 MiB) |
BAO_OIDC_MAX_RESPONSE_BYTES |
VAULT_OIDC_MAX_RESPONSE_BYTES |
no | Maximum GitHub Actions OIDC response body size in bytes (default: 65536 / 64 KiB) |
BAO_REVOKE_TOKEN |
– | no | Token cleanup policy: auto (default), always, or never; overridden by --revoke-token. |
BAO_SECRET_PREFIX |
– | no | Prefix for secret variables (default: SECRET_); overridden by --secret-prefix |
Nonempty BAO_* values take priority over their VAULT_* fallbacks. Authentication selects the first applicable method: direct token → JWT role (with an explicit JWT or GitHub OIDC) → AppRole credentials. A selected method does not fall back to another method if credentials are missing or login fails. BAO_AUTH_PATH is ignored for direct tokens.
Token ownership follows the selected authentication method: tokens supplied through BAO_TOKEN or VAULT_TOKEN are borrowed; client tokens issued by JWT/OIDC or AppRole login are owned. Under the default auto policy, only owned tokens are revoked. This changes the previous behavior that revoked supplied tokens. To retain that behavior for disposable tokens:
bao-wrapper run --revoke-token=always -- make testPolicy precedence is CLI → nonempty BAO_REVOKE_TOKEN → auto. Values must be exactly auto, always, or never; invalid values fail before authentication. There is no VAULT_REVOKE_TOKEN fallback. With never, even login-issued tokens remain valid until expiry or external revocation.
OpenBao/Vault and GitHub OIDC requests have a 10-second HTTP client timeout and do not follow redirects. Secret reads and token revocation retry network errors and HTTP 502/503/504 up to three times, subject to request deadlines; login and OIDC requests are not automatically retried.
Define secrets with the configured prefix (default SECRET_) using this format:
<PREFIX><NAME>=<engine>://[[field][:type]@]path
An explicit supported engine scheme is required. Prefixed variables without one are skipped during secret discovery and stripped from the child environment. The field and type are optional. Query parameters are not supported. file delivery uses an isolated temporary directory; the destination cannot be configured.
| Component | Default | Description |
|---|---|---|
engine |
(required) | Secret engine type. Only kv, legacy, and template are supported. kv uses the KV v2 API path; legacy uses the KV v1 API path; template renders a Go template stored in KV. |
field |
(empty) | Key in the secret data; empty = full JSON. Requires @ separator. |
type |
env |
env = env var, file = temp file path. Specified as field:type before @. |
path |
(required) | Full path to the secret, including the mount point (e.g. kv/test, kvv1/my/secret) |
| Engine | API path used | Description |
|---|---|---|
kv |
GET /v1/<mount>/data/<secret_path> (KV v2) |
KV v2 secrets. The path includes the mount point; /data/ is inserted automatically. Example: path kv/test → /v1/kv/data/test |
legacy |
GET /v1/<path> (KV v1) |
KV v1 path style. The path is used as-is after validation. Example: path kvv1/test → /v1/kvv1/test |
template |
(fetches template from KV v2, then renders) | Fetches a Go text/template from a KV v2 path, renders it with {{ secret "..." }} support (see Template engine) |
Path restrictions: Secret paths must be canonical and relative, without traversal or redundant separators.
legacyalso rejects OpenBao's reservedauth/,sys/,identity/, andcubbyhole/prefixes because these are system endpoints, not KV v1 mounts. This restriction also applies tolegacy://references used inside templates.
The child process receives the bare name without the prefix.
# Full format: engine, field, type, and path (path includes mount point)
SECRET_DB_PASS=kv://password:env@kv/myapp/db
# Write TLS cert to a temp file; TLS_CERT contains the file path
SECRET_TLS_CERT=kv://cert:file@kv/myapp/tls
# Path only (no field or type — reads full JSON from kv)
SECRET_KEY=kv://kv/myapp/db
# Render a config from a KV-stored template into a temporary file;
# APP_CFG contains its path
SECRET_APP_CFG=template://tpl:file@kv/myapp/config
# Legacy KV v1 engine
SECRET_TOKEN=legacy://token:env@kvv1/my/pathThe template engine fetches a Go text/template from an OpenBao/Vault KV secret, renders it, and returns the result. This is useful for generating configuration files that embed multiple secrets.
Use the template engine in the SECRET_* variable:
# Render template stored at KV path "kv/myapp/config", field "tpl",
# write the result to a temporary file, and expose its path as APP_CFG
SECRET_APP_CFG=template://tpl:file@kv/myapp/config
# Render template and inject as an env var
SECRET_APP_CFG=template://tpl@kv/myapp/configInside the template, use {{ secret "url" }} to reference individual secrets. The in-template URL uses a reduced syntax compared to the main SECRET_* declaration:
{{ secret "[engine://][field@]path" }}
The engine defaults to kv when omitted. The field is optional; omitting it returns the full JSON data.
Restrictions for in-template URLs:
- Only
kvandlegacyengines are allowed (notemplaterecursion). - Type selectors (
:env,:file) are not allowed. - Query parameters (
?key=value) are not allowed.
Suppose the KV path kv/myapp/config field tpl contains this Go template:
[database]
host = db.example.com
password = {{ secret "kv://password@kv/myapp/db" }}
[api]
token = {{ secret "kv://token@kv/myapp/api" }}
With the declaration:
SECRET_APP_CFG=template://tpl:file@kv/myapp/configThe wrapper fetches and renders the template, writes it to a temporary file, and exposes the path as APP_CFG. Cleanup attempts to remove the file after use; see Security notes.
Only values fetched through {{ secret "..." }} are registered for template masking. The template source and complete rendered output are not registered. Literal credentials in a template and transformed values therefore have no automatic masking guarantee. The usual minimum length of four bytes applies.
This example uses a JWT auth method mounted at auth/gitlab and requires a release supporting BAO_AUTH_PATH. It assumes your project provides an npm build script and installs its dependencies before the build.
# .gitlab-ci.yml
stages:
- build
build:
stage: build
image: node:24-bookworm@sha256:3d27e5c11e5786e309ec3e03f93ae536eb36e6e5eb3714d5eb3300a36157add0
id_tokens:
BAO_JWT_TOKEN:
aud: https://vault.example.com
variables:
BAO_ADDR: "https://vault.example.com"
BAO_NAMESPACE: "mynamespace"
BAO_AUTH_PATH: "gitlab" # JWT method mounted at auth/gitlab
BAO_JWT_ROLE: "gitlab-ci"
# Inject the NPM token as an env var
SECRET_NPM_TOKEN: "kv://npmToken:env@kv/frontend/ci"
# Render a Docker config into a temporary file; DOCKER_CFG contains its path
SECRET_DOCKER_CFG: "template://tpl:file@kv/ci/docker-config"
before_script:
- |
BAO_WRAPPER_VERSION=v0.7.0
BAO_WRAPPER_SHA256='a5501404e717b4fd39b5597c5f390019c6656543112f5079c2fbac0368fbff17'
curl -fsSL \
"https://github.com/philhartung/bao-wrapper/releases/download/${BAO_WRAPPER_VERSION}/bao-wrapper-linux-amd64" \
-o bao-wrapper-linux-amd64 &&
printf '%s %s\n' "$BAO_WRAPPER_SHA256" bao-wrapper-linux-amd64 | sha256sum --check --strict &&
install -m 0755 bao-wrapper-linux-amd64 /usr/local/bin/bao-wrapper || exit 1
script:
- bao-wrapper run -- npm run buildThe build receives NPM_TOKEN and the temporary config path in DOCKER_CFG. The token and secrets fetched inside the template are registered for log masking.
With id-token: write permission and BAO_JWT_ROLE set, the wrapper can request a JWT using GitHub's ACTIONS_ID_TOKEN_REQUEST_* variables. Configure the Vault role to accept GitHub's issuer, audience, and repository claims. This example assumes your project provides an npm build script and installs its dependencies before the build.
# .github/workflows/build.yml
name: Build
on: [push]
permissions:
id-token: write # required for OIDC token generation
contents: read
jobs:
build:
runs-on: ubuntu-26.04
env:
BAO_ADDR: "https://vault.example.com"
BAO_NAMESPACE: "mynamespace"
BAO_JWT_ROLE: "github-actions"
# Inject the NPM token as an env var
SECRET_NPM_TOKEN: "kv://npmToken:env@kv/frontend/ci"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install bao-wrapper
run: |
BAO_WRAPPER_VERSION=v0.7.0
BAO_WRAPPER_SHA256='a5501404e717b4fd39b5597c5f390019c6656543112f5079c2fbac0368fbff17'
curl -fsSL \
"https://github.com/philhartung/bao-wrapper/releases/download/${BAO_WRAPPER_VERSION}/bao-wrapper-linux-amd64" \
-o bao-wrapper-linux-amd64 &&
printf '%s %s\n' "$BAO_WRAPPER_SHA256" bao-wrapper-linux-amd64 | sha256sum --check --strict &&
sudo install -m 0755 bao-wrapper-linux-amd64 /usr/local/bin/bao-wrapper || exit 1
- name: Build
run: bao-wrapper run -- npm run buildNote:
BAO_JWT_TOKENis intentionally omitted.bao-wrapperdetects the GitHub Actions OIDC environment and requests the JWT automatically. Explicitly settingBAO_JWT_TOKEN(or its fallbackVAULT_JWT_TOKEN) always takes precedence over auto-detection.
For non-interactive environments where JWT/OIDC is not available, you can use AppRole authentication by providing a RoleID and SecretID.
export BAO_ADDR="https://vault.example.com"
export BAO_APP_ID="my-role-id"
export BAO_APP_SECRET="my-secret-id"
# export BAO_AUTH_PATH="ci-approle" # if AppRole is mounted at auth/ci-approle
# Inject secrets from KV
export SECRET_API_KEY="kv://apiKey@kv/services/api"
bao-wrapper run -- ./start-service.shbao-wrapper logs in with AppRole, fetches the secrets, runs the command, and attempts to revoke the client token during cleanup under the default auto policy.
| Package | Responsibility |
|---|---|
main |
CLI options, authentication selection, secret resolution, token lifecycle |
parser |
Secret declarations and template lookup syntax |
api |
OpenBao/Vault HTTP requests, path validation, retries, token revocation |
template |
Go template rendering and collection of inner secrets for masking |
runner |
Child environment, temporary files, process execution, signals, cleanup |
masker |
Streaming plaintext replacement in stdout and stderr |
- Transport: Use an HTTPS
BAO_ADDRoutside local testing; plain HTTP is accepted. HTTPS certificate verification is enabled, with optional custom roots viaBAO_CACERT. GitHub OIDC requires HTTPS and uses system trust; its request URL comes from the CI environment and has no hostname allowlist. - Child environment: Inherited variables starting with
BAO_,VAULT_,ACTIONS_ID_TOKEN_REQUEST_, or the configured secret prefix are removed case-insensitively before resolved secrets are added. Other environment variables are inherited. - Masking: Only exact matches of registered values at least four bytes long are masked in captured stdout and stderr. Full-JSON reads register the JSON string, not its individual fields; templates register only inner lookups. Encoding, escaping, splitting a value between streams, or writing elsewhere can bypass masking. Run trusted child code: masking is not a security boundary. Output may be buffered by up to the longest registered value minus one byte to handle matches split across writes.
- Temporary files: File secrets use an isolated temporary directory (
0700) and exclusive file creation (0600) on Unix. Windows uses inherited ACLs, so protect the account and temporary directory. Cleanup attempts removal on child exit and on SIGINT/SIGTERM, retrying removal after exit if necessary. - Token cleanup: When the revocation policy selects a token for cleanup, cleanup attempts
POST /v1/auth/token/revoke-self, including after failures before child startup. By default, JWT/OIDC and AppRole client tokens are revoked; suppliedBAO_TOKENandVAULT_TOKENcredentials are preserved. SIGINT/SIGTERM starts cleanup while the child is running. Revocation can fail, and forced termination such as SIGKILL prevents cleanup. Use short TTLs for login-issued tokens and disposable tokens selected for revocation; a lost login response can also leave an issued token unavailable for revocation. Tokens are not renewed. - Signals and exit status: The wrapper attempts to forward SIGINT/SIGTERM to its direct child. Process-tree termination and shutdown deadlines are the CI or container runtime's responsibility; Unix-style signal forwarding is not supported on Windows. Normal child exit codes are preserved, but a cleanup failure turns a successful exit into failure; signal termination is reported as exit code 1.
- Output draining: After the direct child exits, the wrapper allows up to one second for inherited stdout/stderr pipes to close before starting cleanup. At the deadline it closes the capture pipes, potentially truncating descendant output. A drain timeout after a successful child exit reports an error and exit code 1; nonzero child exit codes are preserved. SIGINT/SIGTERM still starts cleanup immediately. Descendants are not terminated by this deadline.
- Go, using the version pinned in
go.modand the release workflow - OpenBao, using the version in
integration/openbao.json(for native integration tests)
To run the unit tests:
go test ./...To run tests with coverage:
go test -cover ./...The integration suite runs the compiled wrapper and a Go child helper against a
native OpenBao process on Linux, macOS, and Windows. Install the OpenBao version
pinned in integration/openbao.json on PATH, or set
BAO_TEST_BINARY to the full path to bao (bao.exe on Windows).
The suite never downloads dependencies itself and fails clearly if OpenBao is
missing. Ordinary go test ./... does not require OpenBao.
go test -tags=integration -count=1 -timeout=8m -v ./integrationLocal development only requires an installed OpenBao executable; no installer script or PowerShell is required to run the tests.
CI uses a small PowerShell installer, available on all three hosted runner
platforms. It first verifies the SHA-256 of OpenBao's checksums.txt against
integration/openbao.json, then verifies the native archive against that trusted
checksum list before extracting it. One committed digest therefore pins the
archives for every platform. The release tag and digest are updated together by
Renovate; neither is fetched as an unpinned value during CI.
Each suite builds its own executables, selects a loopback port, and creates isolated storage. A static test-only auto-unseal key and shared declarative self-initialization mount KV v1/v2, seed secrets, and create read-only AppRoles in root and test namespaces; no root token or manual bootstrap is needed. Server and child processes have bounded lifetimes, temporary resources are cleaned up, and server logs are printed on failures. Independent invocations can run concurrently.
The scenarios cover authentication, namespace selection through BAO_NAMESPACE
and VAULT_NAMESPACE (including BAO precedence), KV engines, environment and
file delivery, template masking, credential stripping, argument/stdin forwarding, Unicode and
space-containing paths, failure handling, cleanup, and token revocation. Unix
also checks file/directory permissions and SIGINT/SIGTERM forwarding; Windows
checks file access and cleanup under inherited ACLs. CI runs the suite on all
three operating systems for PRs, main pushes, and release tags, and requires the
whole matrix before publishing a release. JWT and TLS integration fixtures are
not yet included; transport faults remain covered by mock-server tests.
Docker Compose remains available as an optional manual fixture using the same initialization declarations:
docker compose up --detach --wait openbao
# When finished:
docker compose down --volumes --remove-orphansSet OPENBAO_PORT to change the Compose fixture's default port 8200. It does not
affect the native suite, which always starts its own server. Remove the Compose
volume before restarting if you need to reapply initialization changes.
All fixture credentials and seal keys are public test values and must never be
used in a persistent or production environment.
The project uses GitHub Actions to build binaries for multiple platforms. You can build them locally using:
# Linux
GOOS=linux GOARCH=amd64 go build -o bao-wrapper-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o bao-wrapper-linux-arm64 .
# macOS
GOOS=darwin GOARCH=amd64 go build -o bao-wrapper-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o bao-wrapper-darwin-arm64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o bao-wrapper-windows-amd64.exe .The current release workflow pins the same Go version as go.mod, disables CGO and automatic VCS stamping, removes local paths, and embeds the version from git describe --tags --always and full source commit. To reproduce one asset, start from a clean checkout of its tag and use the same target and flags:
BAO_WRAPPER_VERSION=v0.7.0
BAO_WRAPPER_SHA256='a5501404e717b4fd39b5597c5f390019c6656543112f5079c2fbac0368fbff17'
git clone https://github.com/philhartung/bao-wrapper.git
cd bao-wrapper
git checkout --detach "$BAO_WRAPPER_VERSION"
COMMIT=$(git rev-parse HEAD)
test "$(git describe --tags --exact-match)" = "$BAO_WRAPPER_VERSION"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -buildvcs=false -mod=readonly \
-ldflags="-s -w -X main.version=${BAO_WRAPPER_VERSION} -X main.commit=${COMMIT}" \
-o bao-wrapper-linux-amd64 .
printf '%s %s\n' "$BAO_WRAPPER_SHA256" bao-wrapper-linux-amd64 | sha256sum --check --strictCompare the result with the attested SHA256SUMS entry. Reproduction requires the exact Go toolchain version and target architecture used by the workflow.