Releases: jfudally/wg_manager
Release list
v0.5.0
Added
scripts/wg_bootstrap.sh— VPN-first node bootstrap. A multipurpose
orchestrator-run script that enrolls a node from the operator
workstation:vpnjoins the node to the WireGuard VPN by calling the
control plane directly (POST /<ver>/clients/manualover mTLS), pushes
the renderedwg0.confto the node, and brings the tunnel up;cinc
runsknife bootstrapover the now-up VPN;alldoes both, handing the
assigned VPN IP from the first phase to the second. The tunnel teardown
before rewriting the config preserves the reprovision-safety contract.
Shape tests intests/test_wg_bootstrap_script.py.
Changed
- Peer discovery now clears stale results instead of accumulating
ghosts. Each successful discovery pass is treated as authoritative
for the server: anyDiscoveredPeerrow whose public key was not
observed in the pass is pruned, so a peer removed from a server's
running config disappears from the table on the next run rather than
lingering with an oldlast_seen_at. Pruning runs only after a
successfulwg show— an unreachable host returns early and never
wipes the known peer set. Thediscover_peers_taskresult gains a
prunedcount, and the Discovered Peers dashboard clears its
displayed list the moment a "Discover all servers" run is dispatched
so the table never shows a previous pass's peers while a new run is
in flight. Regression tests intests/test_discovery.pyand
web/__tests__/discovered-peers.test.tsx.
Removed
- The
wg_nodeCinc cookbook (cookbooks/wg_node/). It brought a node
onto the VPN by converging against the Cinc server first, which forced
the Cinc server to be reachable before the node had any VPN connectivity
— i.e. public-facing.scripts/wg_bootstrap.sh(above) replaces it by
flipping the order: VPN first, then Cinc over the tunnel, so the Cinc
server can stay VPN-only/private. Thecookbook-test/cookbook-lint
Makefile targets are dropped with it.
Published images
ghcr.io/jfudally/wg_manager:0.5.0— API + workerghcr.io/jfudally/wg_manager-web:0.5.0— dashboard
Pull either with a granularity-appropriate tag (0.5.0, or a coarser 0.5 / 0 / latest).
Supply-chain attestation
Both images are signed via cosign keyless OIDC and carry an
in-toto CycloneDX SBOM attestation. Verify with:
cosign verify \
--certificate-identity-regexp \
'https://github.com/jfudally/wg_manager/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer \
'https://token.actions.githubusercontent.com' \
ghcr.io/jfudally/wg_manager:0.5.0The SBOMs are also attached as release assets:
sbom-api.cdx.json— CycloneDX SBOM of the API + worker image's Python depssbom-web.cdx.json— CycloneDX SBOM of the dashboard image's Node deps
v0.4.0
Fixed
SSH_CA_VAULT_ALLOWED_USERS/SSH_CA_VAULT_ALLOWED_HOST_DOMAINS
now actually take effect from.env.prod. The prod overlay
hardcoded a small env block onbootstrap-substrateand didn't
forward the SSH-CA role allowlists, so an operator extending
SSH_CA_VAULT_ALLOWED_USERSto add a custom login account (e.g.
justinfudally) saw their edit silently ignored — Compose's
--env-fileonly loads values for YAML interpolation, not for
container env. Vault then kept refusing to sign user certs with
the new principal at client provision time
(<user> is not a valid value for valid_principals).
Adds the passthrough indocker-compose.prod.yml(mirroring the
existingDEFAULT_SUBNETshape), the example entry in
.env.prod.example, and a regression test in
tests/test_compose_prod_bootstrap.pyso a future env-block
cleanup can't quietly regress this.
Changed
-
Register-server flow can now bootstrap the host in the same
task. Replaces the standalone "Bootstrap host" dashboard action
andPOST /bootstrap-hostendpoint that shipped in v0.3.x with
a collapsible "Bootstrap this host first" section inside the
Register-server form. When the operator pastes their OOB SSH
private key there,provision_server_taskopens one
BootstrapSSHRunnersession (TOFU + operator key) before the
regular CA-mode provision session, laying down the SSH CA trust- signed host cert + sshd drop-in. One row, one task, one click
for fresh boxes; same behaviour as today when the box was
already bootstrapped (CLI path, baked AMI).
ServerCreateschema (src/wg_manager/schemas.py) gains
optionalbootstrap_ssh_key_pem+bootstrap_ssh_key_passphrase
fields.POST /serversencrypts both via the crypto backend
(Vault Transit in prod) before queueing and forwards the
ciphertext to the task. Passphrase without PEM is rejected at
the schema layer so the operator's intent isn't silently
dropped.provision_server_task(src/wg_manager/tasks.py) accepts
the new encrypted-bootstrap kwargs, decrypts in worker memory,
and runsbootstrap_host()before opening the CA-mode session.
The bootstrap step is skipped entirely when no PEM was supplied;
today's "you forgot to bootstrap" failure mode
(host cert signed by an untrusted CA) is preserved verbatim.- Dashboard
/servers(web/app/servers/page.tsx) drops the
standalone "Bootstrap host" button; the bootstrap section now
lives inside the Register form behind a<details>toggle,
defaulting collapsed so the common (already-bootstrapped) case
stays one click. - Removed:
POST /bootstrap-host,bootstrap_host_task,
BootstrapHostRequest/BootstrapHostResponseschemas,
api.bootstrapHost()client method, and the matching tests.
The CLI (wg-manager bootstrap-host) still exists for
scripted/CI use that wants the install separate from
registration. - Docs:
docs/deploy/single-host-prod.mdupdates "Path A —
Dashboard" to describe the combined Register-with-bootstrap
flow; Path B (CLI) is unchanged.
- signed host cert + sshd drop-in. One row, one task, one click
Published images
ghcr.io/jfudally/wg_manager:v0.4.0— API + workerghcr.io/jfudally/wg_manager-web:v0.4.0— dashboard
Pull either with a granularity-appropriate tag (vX.Y.Z, vX.Y, vX, or latest).
Supply-chain attestation
Both images are signed via cosign keyless OIDC and carry an
in-toto CycloneDX SBOM attestation. Verify with:
cosign verify \
--certificate-identity-regexp \
'https://github.com/jfudally/wg_manager/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer \
'https://token.actions.githubusercontent.com' \
ghcr.io/jfudally/wg_manager:v0.4.0The SBOMs are also attached as release assets:
sbom-api.cdx.json— CycloneDX SBOM of the API + worker image's Python depssbom-web.cdx.json— CycloneDX SBOM of the dashboard image's Node deps
v0.3.0
Release theme: Vault production posture. Closes the largest
"Known limitations" entry in single-host-prod.md — Vault now
runs with a real file storage backend instead of -dev mode,
and an automated init+unseal layer keeps the operator UX identical
to v0.2.x (edit .env.prod, make prod-up, done). State persists
across container restarts, so the bootstrap-host-installed CA
pubkey on a target host stays valid through a Vault container
restart — the exact bug class that motivated this work.
Added
-
Production-mode Vault with auto-init + auto-unseal (PR #54).
The prod overlay swapsvault server -dev(in-memory, fixed
root token) forvault server -config=/vault/config/vault.hcl
with file storage at/vault/fileon the
wg_manager_vault_datanamed volume.docker/vault/vault.hcl— file storage, HTTP listener on
0.0.0.0:8200, ui on,disable_mlock = truefor portability.
No listener TLS — Vault is the PKI source for every other
cert in the stack, so the listener can't depend on a Vault-
minted cert at boot. A dedicated listener-TLS cycle is the
honest next step.scripts/vault_init_unseal.sh— idempotent state machine
that runs fromprod_bootstrap_substrate.shBEFORE the engine
bootstraps. Probes/v1/sys/init+/v1/sys/seal-status. On
uninit: callsvault operator initvia the HTTP API with 5
shares / 3 threshold, captures the JSON output to
${VAULT_INIT_FILE}(default/app/vault-init.json) at mode
0600 owned by UID 1001 (the wg-manager runtime UID), then
unseals. On sealed: reads the same file and unseals. On
unsealed: no-op.docker/entrypoint-wg-manager.sh— tiny shim baked into
the wg-manager image. Readsvault-init.jsonwhen present +
non-empty, exports theroot_tokenfield asVAULT_TOKEN
beforeexec-ing the CMD. First-boot tolerant (empty file
→ no-op).docker-compose.prod.ymlvault service dropsVAULT_DEV_*VAULT_TOKENenv (blanked to""so Compose's environment-
map merge doesn't ride the dev compose's values through).
entrypoint: ["vault"]bypasses the official image's
docker-entrypoint.shwhich would otherwise APPEND
-dev-listen-addressand race vault.hcl's listener.
Healthcheck override accepts 501 (uninit) / 503 (sealed) as
healthy via?standbyok=true&sealedcode=204&uninitcode=204
so bootstrap-substrate'sdepends_on: { vault: service_healthy }trips on the listener being up
rather than the substrate being bootstrapped.
- api / worker / bootstrap-app lose their
VAULT_TOKEN
env — the entrypoint shim sources it fromvault-init.json
instead. All four containers bind-mountvault-init.json
(substrate writable, the rest read-only). .env.prod.exampledropsVAULT_ROOT_TOKEN(auto-
generated now), adds optionalVAULT_KEY_SHARES/
VAULT_KEY_THRESHOLD(defaults 5 / 3), documents the backup
story (back upvault-init.jsonalongside.env.prod).single-host-prod.mdKnown Limitations table flips the
"Vault in dev mode" row to "Vault unseal keys live on disk
invault-init.json" — the honest residual gap. Real
cloud-KMS auto-unseal (transit / awskms / gcpckms) is the
next step but out of scope without cloud creds. The "Where
state lives" table grows the three-file backup story
(.env.prod+vault-init.json+tls/).- End-to-end verified on rv.vpn: clean
make prod-upfrom
zero state brings all 7 services healthy. Smoke on
/healthz,/readyz,/tenants(default-tenant
subnet_poolreflects the operator'sDEFAULT_SUBNET),
/crypto/status(Vault Transit backend) all 200. Critical
restart test:docker restart wg_manager_vault→ sealed →
re-runbootstrap-substrate→ auto-unseals from existing
vault-init.json→ all engine bootstraps log "already
present" → Phase 1 complete. Final status:
storage_type: file, initialized: true, sealed: false.
Five bugs surfaced + fixed during verification (the full diary
is in PR #54's body): vault image'sdocker-entrypoint.sh
mangling theservercommand, missingurllib.errorimport,
unquoted heredoc backtick command-substitution,
vault-init.jsonUID mismatch from the Makefiletouch, and a
duplicate stale wait loop inprod_bootstrap_substrate.sh.Tests: 47 new cases across three new files plus extensions
to four existing files. Backend pytest 1027/1027 in local
mode (was 989 on v0.2.x's merge). -
docs/deploy/single-host-prod.mdgained an "Onboarding a
target host (SSH CA install)" section (PR #53). Recipe for
runningwg-manager bootstrap-hostfrom inside the prod stack
viadocker compose run --rm, mounting~/.sshread-only so
the operator's pre-existing key reaches the container without
exposing the rest of$HOME. The full design + cert profiles
still live indocs/operator-guide.md§3; this is a
deployment-side recipe rather than a duplicate. Closes the gap
a real operator hit on rv.vpn: aftermake prod-upreturned
and they curl'd the API successfully, the next step ("how do I
get wg-manager onto my first hub box?") wasn't covered in the
deploy doc.
Published images
ghcr.io/jfudally/wg_manager:v0.3.0— API + workerghcr.io/jfudally/wg_manager-web:v0.3.0— dashboard
Pull either with a granularity-appropriate tag (vX.Y.Z, vX.Y, vX, or latest).
Supply-chain attestation
Both images are signed via cosign keyless OIDC and carry an
in-toto CycloneDX SBOM attestation. Verify with:
cosign verify \
--certificate-identity-regexp \
'https://github.com/jfudally/wg_manager/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer \
'https://token.actions.githubusercontent.com' \
ghcr.io/jfudally/wg_manager:v0.3.0The SBOMs are also attached as release assets:
sbom-api.cdx.json— CycloneDX SBOM of the API + worker image's Python depssbom-web.cdx.json— CycloneDX SBOM of the dashboard image's Node deps
v0.2.1
Patch release: closes a single bug class the v0.2.0 verification on
rv.vpn surfaced — DEFAULT_SUBNET (and DEFAULT_WG_PORT) set in
.env.prod were silently ignored by the production stack. The v0.2.0
prod overlay never propagated those ${VAR}s into the containers
that needed them; operators set the override, watched nothing
change, and saw the default tenant come up with the code-default
10.9.0.0/24 regardless.
Fixed
-
DEFAULT_SUBNET+DEFAULT_WG_PORTnow flow from.env.prod
through the prod overlay (PR #50 + PR #51). The two PRs together
cover the full data path: request-time + migration-time.- PR #50 — pass the two env vars through to
api+worker.
Without this,Settings.default_subneton the running api falls
back to the code default (config.py:60=10.9.0.0/24) on
everyPOST /serverspayload that omits thesubnetfield. The
dashboard's "New server" form pre-fills from this value too, so
an operator who set the override in.env.prodwould see the
pre-fill ignore them. - PR #51 — same passthrough for
bootstrap-app. Alembic
migration0016_add_tenant_subnet_pool.py:68reads
live_settings.default_subnetAT MIGRATION TIME to backfill the
default tenant'ssubnet_pool. The migration runs inside
bootstrap-app(the container that doesalembic upgrade head),
NOT inside api/worker. So even after PR #50 fixed the request-
time path, the default tenant'ssubnet_poolstill came up at
10.9.0.0/24on every fresh-volume boot becausebootstrap-app
was missing the env. PR #51 closes the loop.
Verified end-to-end on rv.vpn after both PRs landed: with
DEFAULT_SUBNET=10.8.0.0/24in.env.prod,make prod-down -v+
make prod-upbrings the default tenant up with
subnet_pool='10.8.0.0/24'(confirmed via both
SELECT * FROM tenantdirectly against MySQL and
GET /v1/tenantsthrough the mTLS API).Operator remediation on a stack already bootstrapped with the
buggy v0.2.0 default:- A.
make prod-down -v+make prod-upon v0.2.1 — re-runs
migration 0016 against the env-correctedbootstrap-app. Wipes
state. - B. Manual SQL —
UPDATE tenant SET subnet_pool='<your-cidr>' WHERE id=1. Preserves state.
.env.prod.examplegrew a documentedDEFAULT_SUBNET+
DEFAULT_WG_PORTblock explaining when an operator overrides
(overlapping VPN address space with another stack, conflicting
host routes). Tests grew two parametrised cases pinning the
passthrough on api + bootstrap-app so the bug class can't
re-emerge silently. - PR #50 — pass the two env vars through to
Published images
ghcr.io/jfudally/wg_manager:v0.2.1— API + workerghcr.io/jfudally/wg_manager-web:v0.2.1— dashboard
Pull either with a granularity-appropriate tag (vX.Y.Z, vX.Y, vX, or latest).
Supply-chain attestation
Both images are signed via cosign keyless OIDC and carry an
in-toto CycloneDX SBOM attestation. Verify with:
cosign verify \
--certificate-identity-regexp \
'https://github.com/jfudally/wg_manager/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer \
'https://token.actions.githubusercontent.com' \
ghcr.io/jfudally/wg_manager:v0.2.1The SBOMs are also attached as release assets:
sbom-api.cdx.json— CycloneDX SBOM of the API + worker image's Python depssbom-web.cdx.json— CycloneDX SBOM of the dashboard image's Node deps
v0.2.0
Release theme: production deployability. The dev compose became
an actual production compose (overlay + self-bootstrap), the HA
control plane went from doc to demo profile, and the observability
- multi-tenancy + API-versioning work that landed since v0.1.0 came
along for the ride. Every shipped path picked up a regression test
the end-to-end verification work surfaced.
make prod-up is the single command from a fresh checkout to a
fully usable production stack: edit five values in .env.prod,
wait ~90 seconds, get an mTLS API + Celery worker + Next.js
dashboard + hardened MySQL + Vault + Valkey on the box.
Added
-
Vault Transit engine + master key auto-bootstrap. Closes the
last gap inmake prod-up:scripts/transit_bootstrap.pyis
invoked byscripts/prod_bootstrap_substrate.shso a fresh
Vault gets the Transit mount + thewg-managermaster key
(derived=True) provisioned automatically. Before this, the
dashboard's Crypto page 500'd with
no handler for route transit/keys/wg-manageron first load
becauseVaultTransitBackendis deliberately dumb and the
cookbook documented Transit setup as a manual step. Also exposed
asmake transit-bootstrapfor operators driving Vault by hand. -
Linux portability fixes for the self-bootstrap (PR #47). PR
#46's self-bootstrap worked on Docker Desktop on Mac but failed
on every step against a real Linux host. Four distinct fixes:- Bootstrap containers declare
user: "0:0"so they can
write to the bind-mounted./tlson Linux hosts where the
operator's UID (typically 1000) doesn't match the wg-manager
image'swgmanagerUID (1001). Docker Desktop on Mac papers
over the mismatch transparently — pure Linux doesn't. End-of-
scriptchown -R 1001:1001puts the cert files back into the
runtime UID so api/worker/web can read them. - *Bootstrap scripts chmod .key files to
0644somysql:8
inside its container (UID 999) can read the server.key even
though the file is owned by UID 1001. Without this, mysqld
fails to load TLS and the app gets
SSL is required but the server doesn't support iton every
connection attempt. WG_MANAGER_API_BIND_PORT+WG_MANAGER_WEB_BIND_PORT
env overrides for operators running multiple stacks on one
host (or fronting the API behind a reverse proxy that owns
443)..env.prod.examplenow recommendsopenssl rand -hex 32
forVALKEY_PASSWORD+MYSQL_APP_PASSWORD— these values
get interpolated intoredis://:${pw}@valkey:...and
mysql+pymysql://wg:${pw}@mysql:...URLs, and the previous
openssl rand -base64recommendation produced strings with
/+=characters that broke URL parsing at startup.
- Bootstrap containers declare
-
Self-bootstrapping
docker-compose.prod.yml. Edit.env.prod,
runmake prod-up, get a fully usable production-shaped stack on
the first invocation — no manual cert minting, no shell exports,
no separatemake migratestep. The previous 9-step runbook
collapses to "fill in 5 values, run one command".- Two run-to-completion bootstrap containers break the
cert/MySQL chicken-and-egg cleanly via Compose's
service_completed_successfullyprimitive:bootstrap-substrate— waits for Vault healthy, runs the
Vault PKI / SSH CA / audit bootstraps (each idempotent), mints
the MySQL server + client cert pair (--type mysql+
--type mysql-clientfor the correct EKU split), exits.
mysql + valkey depend on it completing successfully.bootstrap-app— waits for MySQL + valkey healthy, runs
alembic upgrade head, registers the bootstrap operator,
mints the API server cert + operator CLI client cert, exits.
api + worker + web depend on it completing.
scripts/prod_bootstrap_substrate.sh+
scripts/prod_bootstrap_app.share the two scripts the
bootstrap containers exec. Bothset -euo pipefailand guard
every mutating step with a file-existence test, so re-running
make prod-upagainst persistent state is a no-op (cert files
present → skip mint; operator registered → skip; alembic at
head → no-op).alembicmoved fromdevto runtime deps so the wg-manager
image bakes/app/.venv/bin/alembic— the bootstrap container
needs it, and operators pulling
ghcr.io/jfudally/wg-manager:vX.Y.Zshouldn't have to install
dev deps to apply migrations.make prod-upgains--waitso the command blocks until
every service is healthy or exited 0. When the make target
returns, the stack is fully usable end-to-end..env.prod.examplenow documents the operator identity
(BOOTSTRAP_OPERATOR_CNrequired,BOOTSTRAP_OPERATOR_ROLE
optional with defaultadmin) and the API server cert subject
(API_SERVER_CN+API_SERVER_SANS, both optional with
localhost-friendly defaults). The old comment block that left
BOOTSTRAP_OPERATOR_CNcommented-out is gone — the prod stack
requires it.- Runbook rewrite at
docs/deploy/single-host-prod.md: the
"Bootstrap (one command)" section is the canonical flow; the
old 9-step manual flow is preserved as an "Advanced: manual
bootstrap (for debugging)" section operators can fall back to
when self-bootstrap fails partway and they want shell-level
introspection. - End-to-end verification on a clean Docker host:
make prod-down -v→ striptls/→make prod-up. Sequence:
bootstrap-substrate exited → mysql + valkey healthy →
bootstrap-app exited → api + worker + web healthy. Curl on
/v1/healthz+/v1/readyzreturns 200,/v1/servers
authenticates via the operator cert and returns[], dashboard
returns 200. Re-runningmake prod-upis a clean no-op (all
bootstrap steps log "already exists, skipping"). - Tests: 16 compose-shape cases
(tests/test_compose_prod_bootstrap.py) + 18 script-shape
cases (tests/test_prod_bootstrap_scripts.py) = 34 new. The compose
suite pins the dependency graph (the bootstrap ordering is
encoded entirely independs_onconditions, so a future edit
that breaks the graph trips at test time). The scripts suite
pins shebangs,set -euo pipefail, the substrate script's
invocation of all three Vault bootstrap helpers, the app
script'salembic upgrade head+operators add+ the two
cert mints, and the file-existence-guard pattern.
- Two run-to-completion bootstrap containers break the
-
Phase 3d cycle 4a — docker-compose
haprofile + nginx
passthrough LB. Materialises the two-replica + LB topology from
docs/deploy/ha-control-plane.mdon a single host so an operator
can verify failover end-to-end before deploying a real two-host
setup.- Compose
haprofile. Three new services in
docker-compose.yml, all gated behindprofiles: ["ha"]:
api1(host port 8001),api2(host port 8002), andlb
(host port 8443,nginx:1.27-alpine). Both replicas build the
existing Phase 2fDockerfile, bind-mount the dev cert bundle
intls/read-only, and reuse the default-profile data tier
(mysql + valkey + vault + vector — unprofiled so it comes up
under both flows). - nginx LB config at
docker/nginx/wg-manager.confis
stream {}-mode TCP passthrough — the HA topology forbids TLS
termination at the LB so the mTLS handshake lands on the
replica intact. Two upstreams (api1:8000,api2:8000) with
passivemax_fails=3 fail_timeout=10schecks, single listener
on8443. - Makefile grew
ha-up/ha-down/ha-logsmirroring the
db-upfamily. - Docs.
docs/deploy/ha-control-plane.mdgained a "Running
the ha profile locally" section with the three-endpoint table,
a failover-smoke recipe, and the explicit list of what cycle
4a does not ship (active/readyzprobing at the LB, TLS
termination at the LB, MySQL primary→replica plumbing). - Tests: 19 compose-shape cases
(tests/test_compose_ha_profile.py) + 7 nginx-config cases
(tests/test_nginx_lb_config.py) — pure YAML/text parse-and-
assert, matches the Phase 2ftest_dockerfile.pypattern. CI's
image-build workflow remains the live compose validator; these
tests pin the source-of-truth shape so a refactor that drops a
service or breaks the passthrough contract trips before merge.
- Compose
-
End-to-end verification of the prod overlay surfaced four bugs;
all four fixed. Walking the runbook on a clean Docker host
exercised every shipped code path the overlay touches and turned
up code-level shape mismatches between what the docs promised and
what the code delivered. Each is fixed with a regression test.- Compose
ports:list merge — the overlay's
127.0.0.1:3306:3306mappings were being concatenated with the
dev file's0.0.0.0:3307:3306instead of replacing it, so the
second host-port bind failed silently. Fixed with Compose v2.20's
!overridetag onmysql/valkey/vaultports. Test
fixtures grew a customComposeLoaderso PyYAML tolerates the
!override/!resetCompose-specific tags. - PEM chain newline missing.
VaultPKI._issue_leafjoined the
Vaultca_chainlist with"".join(...)andLocalDevPKI
usedintermediate_pem + root_pem. When the upstream PEM
lacked a trailing newline (Vault's modern API output), the
result was-----END CERTIFICATE----------BEGIN CERTIFICATE-----
on one line — rejected by strict consumers (openssl x509,
Pythonssl.SSLContext.load_verify_locations, pymysql's
ssl={ca: ...}). Newwg_manager.pki._join_pems(*pems)helper
normalises to strict-parser-safe output and is used at both
sites. 10 regression cases intests/test_pki_chain_join.py. - Alembic
env.pyignored MySQL TLS args.
engine_from_config(config_section, prefix="sqlalchemy.")built
its engine wi...
- Compose
v0.1.0
Added
-
Phase 2f cycle 4 — SBOM generation + attestation + release-asset
attachment. Closes the Phase 2e SBOM bullet and the final
Phase 2f cycle. Phase 2 is closed. Every published release now
ships with two CycloneDX 1.5 JSON SBOMs covering the runtime dep
closure of each image, delivered two ways for the verify path the
consumer prefers.- The release workflow's API job runs
cyclonedx-py environment
against the synced.venv(uv sync --frozen --no-devmatches
the production image content) and emitssbom-api.cdx.json.
The web job runs@cyclonedx/cyclonedx-npm --omit devagainst
web/node_modulesafternpm ciand emitssbom-web.cdx.json. - In-toto attestation on the image:
cosign attest --yes --type cyclonedx --predicate sbom-*.cdx.json "${IMAGE}@${DIGEST}"binds each SBOM to the immutable image
digest. Same Fulcio identity as the cycle 3 signature, so a
futurecosign verify-attestationgate proves SBOM provenance
from the canonical workflow path. - Release asset: both SBOMs upload as workflow artifacts and
thereleasejob downloads + attaches them via
gh release create … sbom-*.cdx.json. The release page now
leads with a "Supply-chain attestation" section covering the
canonicalcosign verifyinvocation + SBOM filenames. docs/release.mdgrows a "Software Bill of Materials" section
covering both delivery paths, thecosign verify-attestation --type cyclonedxflow, and ajq-based recipe for diffing
SBOMs between two releases.- 7 new test cases extending
tests/test_release_workflow.py'sTestSbomGeneration:
cyclonedx-py + cyclonedx-npm referenced,cosign atteststep
with--type cyclonedxpredicate, SBOMs uploaded via
actions/upload-artifact, release job uses
actions/download-artifact,gh release createlists the
.cdx.jsonfiles as positional asset arguments.
- The release workflow's API job runs
-
Phase 2f cycle 3 — cosign keyless signing + verify gate.
Closes Phase 2e's "Deferred — cosign verify" bullet. Every image
the release workflow publishes is now signed with cosign keyless
OIDC against GitHub Actions' Fulcio issuer; a separate verify
workflow proves the signatures verify against the canonical
identity..github/workflows/release.ymlextended: each
build-and-push-*job installssigstore/cosign-installer@v3
and runscosign sign --yes "${IMAGE}@${DIGEST}"against the
immutable digest the push step emits. Signing the digest (not
the tag) means a future re-tag inherits the signature.- New
.github/workflows/image-verify.ymlis the consumer-side
gate. Runs onworkflow_dispatch(with ataginput,
defaults tolatest) and on a daily 14:00 UTC cron. Not
on push/PR — verification before the first release is cut
would always fail. Two jobs (verify-api + verify-web) run
cosign verifywith--certificate-identity-regexppinned to
the canonical release workflow path in this repo (catches
fork-workflow / stolen-token / malicious-mirror signatures)
and--certificate-oidc-issuerpinned to GitHub Actions'
Fulcio issuer (catches Fulcio certs from other OIDC
providers). - The cron failure mode is exactly the alerting trigger an
operator wants: a previously-verified image no longer verifies
→ someone tampered with it after publish. docs/release.mdgrows a "Verifying a published image"
section covering the downstream-pull flow + the CI workflow
path + what the identity binding catches.- 16 new test cases:
tests/test_image_verify_workflow.py
× 11 (workflow exists, triggers on dispatch + schedule but
NOT push/PR, dispatch takestaginput, contents-read
least-privilege perms, installs cosign, callscosign verify,
pins identity regexp + OIDC issuer, covers both images); plus
5 cases extendingtests/test_release_workflow.py's
TestCosignSigning(installs cosign, signs pushed images,
--yesflag, signs by digest not tag).
-
Phase 2f cycle 2 — tagged release workflow + GHCR publish.
Closes the second of four Phase 2f cycles. Agit push origin v<X.Y.Z>now produces published images at
ghcr.io/<owner>/wg-manager:v<X.Y.Z>(and-web) plus a GitHub
release with notes extracted fromCHANGELOG.md's matching
## [v<X.Y.Z>]section.- New
.github/workflows/release.ymlwith four jobs:
extract-notes(parses CHANGELOG via the new helper), two
parallel build-and-push jobs for the API + web Dockerfiles,
and areleasejob that creates the GitHub release via
gh release create --verify-tag.docker/metadata-action
derives semver / SHA / latest tags from the git ref.
Permissions:contents: write+packages: write+
id-token: write(cycle 3 layers cosign keyless signing on
the same workflow). Concurrency is not
cancel-in-progress— partial GHCR pushes are messier than
a stuck job. - New
scripts/extract_changelog.pywalksCHANGELOG.mdand
returns the body of the## [vX.Y.Z]section matching the tag
being released. Fails the workflow loudly when the heading is
missing — operators promote## [Unreleased]to the versioned
heading before tagging. - New
make release-notes VERSION=vX.Y.Zwraps the extractor
for local preview. - New
docs/release.mdoperator runbook walks the
promote-Unreleased → tag → workflow flow and two recovery
paths for a mid-flight failure. - 24 new test cases:
tests/test_extract_changelog.py
× 8 (section matching, version-prefix normalisation,
missing-version exit, CLI happy + error paths);
tests/test_release_workflow.py
× 16 (workflow shape, triggers, permissions, GHCR target,
metadata-action, push: true, both Dockerfiles, extractor
shell-out, release creation, no cancel-in-progress).
- New
-
Phase 2f cycle 1 — Dockerfiles + image-build CI gate. Opens
the release-engineering work-stream that Phase 2e deferred (signed
Docker image publish, cosign verify, SBOM attachment). Cycle 1
ships the foundation; cycles 2-4 publish, sign, and SBOM on top.- Multi-stage
Dockerfile— Python 3.13-slim-
bookworm builder runsuv sync --frozen --no-devagainst the
locked deps, slim runtime carries only.venv+src/and
drops to non-rootwgmanageruser UID 1001.
python -m wg_manageris the default CMD. - Multi-stage
web/Dockerfile— Node 22-slim
builder runsnpm ci+npm run build, runtime copies the
.next/standalonebundle and runs as non-rootnextjsUID
1001.web/next.config.tsflipped tooutput: "standalone"
so the standalone copy has something to copy. - New
.github/workflows/image-build.yml
builds both images on every PR + push tomainvia
docker/build-push-action@v6with GHA layer cache.push: falsepinned so cycle 1 explicitly does not publish (cycle 2
territory). Path-filtered to dep manifests + source dirs so
code-only PRs skip the ~3-min image build. - Latent build bugs surfaced + fixed: the tailwindcss v3→v4
dependabot bump leftnext devworking butnext build
broken. (a) PostCSS plugin moved to@tailwindcss/postcss—
web/postcss.config.mjsupdated.
(b) v3@tailwind base/components/utilitiesdirectives
dropped —web/app/globals.css
migrated to@import "tailwindcss"+@themeblock with
--color-*tokens replacing thetailwind.config.ts
theme.extend.colorsblock. (c) Pre-existing
web/lib/proxy.ts:124Uint8Array<ArrayBufferLike>↔
BodyInitcomplaint (flagged out-of-scope in 2d CP3.4)
blocksnext build— patched with the documented type
workaround. None of this regressed before cycle 1 because the
existing CI runs vitest only (notnext build). - 26 new test cases:
tests/test_dockerfile.py
× 18 (Dockerfile existence, multi-stage shape, slim base,
Python 3.13 pin, uv--frozen, non-root user, WORKDIR,
default CMD; same shape for web/Dockerfile + standalone-output
check on next.config.ts);tests/test_image_build_workflow.py
× 8 (workflow exists with descriptive name, triggers on PR +
push to main, path-filtered, usesdocker/build-push-action,
references both Dockerfiles by path, pinspush: false,
cancels-in-progress concurrency, contents-read least-privilege
permissions). Pure parse-and-assert — the livedocker build
is what the CI workflow runs.
Backend pytest 632/632 green (was 606 on Phase 2e cycle 4).
Vitest 46/46. Local smoke: bothdocker buildinvocations
succeed. - Multi-stage
-
Phase 2e cycle 4 —
wg-manager evidence packSOC 2 evidence
tarball. Closes the ROADMAP Phase 2e stretch acceptance bullet.
Newwg-manager evidence pack --output PATH --since-days N --vault-audit-log PATHCLI command +wg_manager.evidence
module assemble a tar.gz an auditor can verify end-to-end.- Pack contents:
audit_events.json(auditevent table filtered
to last N days),certificates.json+operators.json(full
registry dumps — current state, no date filter),vault_audit.log
(Vault audit file sliced to the same window by parsing each
line'stimefield),vault_audit_integrity.json(per-line
JSON parseability +timefield presence + request/response
request.idpairing — honest about being structural only
because Vault does not ship a cryptographic chain across audit
records),system.json(wg-manager version, git commit,
alembic head), `M...
- Pack contents: