# Release Engineering

How Donjon Platform is versioned, built, sanitized, signed, and published.
This is the system that replaces manual `wrangler` pushes and the ad-hoc
zips that let v7.5.1 ship with internal IPs and an inverted version number.

## 1. Versioning scheme (clean SemVer, single source of truth)

`MAJOR.MINOR.PATCH` — three numeric parts, no suffixes, strictly increasing.

| Part  | Bump when |
|-------|-----------|
| MAJOR | breaking change to the product, license format, or report schema |
| MINOR | new feature, backward-compatible |
| PATCH | bug fix / security fix, no new feature |

- **Single source of truth: `pyproject.toml` `[project].version`.**
  `bin/release_lib.read_version()` reads it and *cross-checks* `lib/config.py`
  (`'version': '...'`). If they disagree the build **fails** — no more silent
  drift between `pyproject`, `lib/config.py`, `lib/cli.py`, `lib/demo.py`.
- **Monotonic guard.** `assert_version_advances(new, last)` refuses to build a
  version `<=` the last published one. This is the direct fix for the
  "7.5.1 predates 7.5.0" inversion: if 7.5.1 is already out, you cannot build
  7.5.0 or re-build 7.5.1. The release workflow feeds `--last-version` from the
  previous git tag.
- **Tag = version.** The `release.yml` workflow asserts the pushed tag
  (`v7.5.2`) equals the `pyproject` version before building.

To cut a release: bump `pyproject.toml` **and** `lib/config.py` to the new
SemVer, commit, tag `vX.Y.Z`, push the tag.

## 2. Reproducible, sanitized build

`python bin/build-release.py [--throwaway]` → `dist/donjon-platform-<v>.zip`
plus `.sig`, `.pqc.sig`, `.manifest.json`.

**Deterministic:** fixed zip timestamps + sorted entries → identical SHA-256
across rebuilds (verified). Sanitization strips, from the customer artifact:

- runtime state: `data/`, `results/`, all `*.db`, logs
- trial/EULA markers: `trial-license.json`, `.trial-used`, `eula_accepted.json`
- dev/CI trees: `.git/`, `.github/`, `.forgejo/`, `.claude*`, `tests/`
- `tools/` entirely — internal factory/dev scripts (which leaked the factory
  host, Forgejo URL and the DB password) **and** vendored third-party tool
  binaries (fetched at deploy via `bin/bundle-tools.py` for the air-gap flow)
- private keys (`*.pem`, `keys/`), credentials, `__pycache__`

**Content rewrite (in-flight, repo never modified):** the founder's real lab
hosts used as "example"/default values in product code/docstrings are rewritten
to RFC5737 documentation addresses (`192.0.2.x`). The example CIDR
`192.168.1.0/24` and the RFC1918 range constants `192.168.0.0/16` /
`172.16.0.0/12` are preserved — they are a network scanner's legitimate domain,
not a leak.

**Fail-closed gate:** after sanitization the build re-scans every file; any
surviving real-lab IP (`192.168/16`, `172.16/12`) or secret aborts the build.
`bin/verify-artifact.py <zip>` runs the same assertion standalone and in CI.

## 3. Dual signing (reuses existing keys)

Every artifact is signed with **both**:

- **Ed25519** (classical) — `keys/donjon-private-classical.pem` (PKCS8 PEM)
- **ML-DSA-65** (FIPS 204, post-quantum) — `keys/donjon-private-pqc.bin` (4032-byte secret key)

These are the **same keys** that sign paid licenses. They are **reused, never
regenerated** — generating new keys would orphan every issued license. Keys
live in `donjonsec-infrastructure/keys/`, mirrored on CT100
`/opt/donjonsec-keys/`; `build-release.py` auto-discovers them. CI verification
uses `--throwaway` ephemeral keys so real keys never touch the runner except in
the gated tag-release job.

## 4. CI/CD

| Workflow | Repo | Trigger | What |
|----------|------|---------|------|
| `ci.yml` (`test-gate` + `release-build-check`) | scanner | push/PR | pytest gate (redteam + version + report-quality + release-build), blocks on red; then throwaway sanitized build + `verify-artifact` |
| `release.yml` | scanner | tag `vX.Y.Z` | test-gate → real-key sign → verify → publish |
| `deploy.yml` | website | push main | Astro build + IP gate → Cloudflare Pages → smoke check |
| `deploy.yml` | infrastructure | push main | admin-portal tests → deploy license + admin Workers → smoke checks |

The deploy pipelines call `wrangler deploy` / `wrangler pages deploy` directly
and **do not edit `wrangler.toml`** (owned by the observability track).

## 5. Authoritative release channel

**`releases.donjonsec.com`** on the OCI A1 instance (`129.213.159.23`,
Always Free) is the single source of distribution. Forgejo remains SCM source
of truth; the GitHub `releases` remote is retired. One channel ends the
live/repo drift. `bin/publish-release.py` (dry-run by default) verifies the
artifact then uploads `zip + .sig + .pqc.sig + .manifest.json` to
`donjon-platform/<version>/` and updates `donjon-platform/latest.json`.

## 6. What still needs Cris (gated)

- OCI standup of `releases.donjonsec.com` (host/bucket + web root) and the DNS record.
- Forgejo Actions runner secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`,
  `DONJON_RELEASE_HOST`, `DONJON_RELEASE_SSH_KEY`, and the reused signing keys
  (`DONJON_ED25519_KEY`, `DONJON_MLDSA_KEY` base64) — or mount keys at
  `/opt/donjonsec-keys` on the runner.
- Enabling the self-hosted CI runners and the first live deploy.
- Real-key signing of the first published release.
