RELEASE_PROCESS.md
Rama master · réplica de lectura del repositorio original.
# Phlix Release Process
## Versioning strategy
Phlix follows [Semantic Versioning (SemVer)](https://semver.org/):
- **MAJOR** version: Incompatible API changes between server and hub
- **MINOR** version: New backward-compatible functionality
- **PATCH** version: Backward-compatible bug fixes
Example: `v1.2.3` where:
- `1` = major version
- `2` = minor version
- `3` = patch version
## Server-hub compatibility
> ⚠ **The matrix previously printed here ("server and hub must have
> matching major versions") was never real and is falsified by the live
> estate:** the shipping pair is server `1.2.3`
> (`src/Common/Version.php::STRING`) × hub `0.5.0` (hub `VERSION` marker),
> and they interoperate. The two products do not share a version space and
> are not released in lockstep, so matching majors is neither required nor
> meaningful until a coordinated 1.0 (pending the owner's
> release-authority decision, same caveat class as the tag table below).
What actually governs interop today:
1. **The shared wire contract.** Both repos vendor the generated contract
artifacts of the pinned `@phlix/contracts` release — the error-code
registry (`tests/Fixtures/Contracts/error-codes.json`, asserted by
`tests/Unit/Contracts/ErrorCodesContractTest.php` on the server; the
hub carries its own mirror) — and each repo machine-checks its
`openapi.yaml` against its live router. A contract-breaking change
requires coordinated releases on both sides, each proven green against
the pinned contract version in its own CI.
2. **Per-release-pair acceptance.** Until both products reach a coordinated
1.0, compatibility is asserted by the pair that was CI-tested together at
release time — not by a version formula.
3. **Version advertisement, not version enforcement.** The server reports
`Version::STRING` on `/health` and `/system/info` (and via the published
`VERSION` marker to the update check). There is **no** compatibility
endpoint — the formerly advertised `/api/v1/compatibility` never existed
in this repo's router or `openapi.yaml`, and the hub enforces no
version gate on relay registration. The only runtime version floor that
*is* enforced is plugin → server: `PluginLoader` validates each
manifest's `phlix_min_server_version` against `Version::STRING`.
`composer.json`'s `extra.phlix.minHubVersion` is a declared floor with
no read sites — a statement of intent, not an enforcement point.
## Release channels
### Stable
- Production-ready releases
- Minimum 2 weeks of beta testing
- Semantic version tags (e.g., `v1.2.3`)
### Beta
- Pre-release testing
- Tagged with `-beta.N` suffix (e.g., `v1.3.0-beta.1`)
- May contain breaking changes
### Nightly (dev)
- Continuous builds from `master` by the `Docker Build & Push` workflow
(runs on every push to `master`): three legs publish mutable `latest`,
`intel` and `nvidia` tags plus deterministic immutable
`<full-sha>-<variant>` tags.
- No stability guarantees.
- ⚠ `nightly-YYYYMMDD` image tags are **NOT-YET-PUBLISHED** — no nightly
workflow exists and the registry serves none (S483DOCTRUTHX9P2,
measured against `ghcr.io/v2/detain/phlix-server/tags/list` 2026-09-11).
The `master`-push regime above is the real continuous-build channel;
date-stamped nightly tagging awaits the owner's release-authority
decision and should not be relied on until it ships.
## Release schedule
| Release Type | Frequency | Example |
|--------------|-----------|---------|
| Patch | As needed | v1.2.1 released 2 weeks after v1.2.0 |
| Minor | Monthly | v1.3.0 on 3rd Thursday |
| Major | 6-12 months | v2.0.0 with breaking changes |
## Pre-release checklist
- [ ] All tests pass on `master`
- [ ] Coverage >= the `MIN_COVERAGE` floor declared in `.github/workflows/phpunit.yml` (the live gate; AGENTS.md — never set a coverage demand above measured coverage or every run turns red, making the checklist false-by-construction)
- [ ] PHPStan level 9 clean
- [ ] PHPCS PSR-12 clean
- [ ] Changelog updated
- [ ] Version bumped with `./scripts/release.sh` (see below — it bumps every source at once)
- [ ] Docker images built and pushed
- [ ] Draft release created and reviewed
- [ ] Release notes reviewed
## Release steps
### 1. Start from current master
```bash
git checkout master
git pull
```
Releases land as direct commits on `master`. (This page previously prescribed
a release branch here; no release branch exists in the repo's release history
— v1.2.3 is the direct master commit `c17bb9ec` — and `scripts/release.sh`
itself closes by printing `git push && git push origin v$NEW_VERSION`. The
branch+PR ceremony was fiction and has been corrected to the practiced flow.)
### 2. Update version
**Never edit the version by hand.** Run:
```bash
./scripts/release.sh [patch|minor|major] [--dry-run]
```
`src/Common/Version.php::STRING` is the **authoritative** version source — it is
what the API reports, what `PluginLoader` enforces `phlix_min_server_version`
against, and what the core-update check compares the published marker to.
The script reads it and writes the bumped value to every mirror in one commit:
| Source | Role |
|---|---|
| `src/Common/Version.php` `public const STRING` | authoritative |
| `VERSION` (repo root) | the update marker every deployed server polls |
| `k8s/helm/phlix/Chart.yaml` `version:` + `appVersion:` | Helm chart |
`composer.json` deliberately carries **no** `version` field, and the script will
never add one: the `composer-validate` CI job runs
`composer validate --strict --no-check-publish`, which exits non-zero when the
field is present (that was the decision in `f5375f7a`).
The script refuses to release from a tree where those sources disagree, and
`tests/Unit/Server/Updates/VersionSourcesAgreeTest.php` +
`tests/Unit/Scripts/ReleaseScriptTest.php` keep any drift red in CI.
`extra.phlix.minHubVersion` in `composer.json` is a compatibility floor, not a
version of this package — bump it only when the server actually starts
requiring a newer hub.
### 3. Update changelog
Generate changelog:
```bash
git log --pretty=format:"- %s" v1.1.0..HEAD
```
### 4. Commit and push on master
```bash
git add .
git commit -m "Release v1.2.0: <one-line summary>"
git push
```
### 5. Tag and release
After pushing `master`:
```bash
git tag v1.2.0
git push origin v1.2.0
```
GitHub Actions will:
1. `Release` workflow (trigger `v*.*.*`), job "Verify version sources match
tag": strip any prerelease suffix from the tag, then assert the
**authoritative trio that `scripts/release.sh` writes** all match the
resulting MAJOR.MINOR.PATCH base — `src/Common/Version.php::STRING`, the
`VERSION` marker, and `k8s/helm/phlix/Chart.yaml`'s `version:` and
`appVersion:` — and that `composer.json` carries **no** `version` field
(the `f5375f7a` law above; the assert is law-enforcing, not
law-contradicting). It then lints and packages **both** charts and
creates the GitHub release ("Phlix Server <tag>"; `alpha`/`beta` in the
tag name marks it a prerelease) with the chart `.tgz` files attached as
**release assets**.
* The **hub** chart (`k8s/helm/phlix-hub/Chart.yaml`) is packaged but
deliberately **not** asserted against the server tag: per "Server-Hub
Compatibility" the two products share no version space, and nothing in
`scripts/release.sh` bumps it. The previous wording here claimed the
workflow "asserts both Helm charts' `appVersion`"; that claim — plus
the matching assert — is exactly why **every** tagged `Release` run in
this repo's history (v1.2.0 … v1.2.3, attempts included) failed at
"Assert Chart.yaml appVersion matches tag": the hub chart sat, and
still sits, at `appVersion: "1.0.0"` under a stale comment that no
tooling ever honored. A green end-to-end `Release` run requires a real
tag push, which is owner release authority — the step logic is
verified by dry-run replay, not by a published run (same disclosure
precedent as the windows WIN-05 upload leg).
* It likewise no longer asserts `composer.json`'s `version` equals the
tag. That assert and this document's own no-`version`-field law were
mutually exclusive: the check could only "pass" on a tagged tree that
violated `composer validate --strict` — which is what the v1.2.3 tag
tree did.
2. `Docker Build & Push` workflow (trigger `v*`): rebuild and publish the
three runtime image legs with mutable `latest`/`intel`/`nvidia` tags
plus the immutable `<full-sha>-<variant>` tags for that commit
**Not yet true (pending the owner's release-authority decision):**
no workflow pushes GHCR tags named `v1.2.0` or `v1.2` (the registry serves
zero semver image tags — see the tag table below), and nothing "pushes" a
Helm chart to any repository — charts ship only as release assets. Steps 3–4
as previously worded here described an intended flow that has never run.
## Docker image tagging
Published today (current regime — measured against the live registry, see the
recipe below):
| Tag | Description | Example |
|-----|-------------|---------|
| `<full-sha>-latest` / `-intel` / `-nvidia` | **Deterministic immutable tag per commit and leg** — the form to pin | `ghcr.io/detain/phlix-server:<full-sha>-latest` |
| `latest` / `intel` / `nvidia` | Mutable convenience tags, repushed by every `master` (and `v*`) build — may be stale or move under you | `ghcr.io/detain/phlix-server:latest` |
| `buildcache-latest` / `-intel` / `-nvidia` | BuildKit registry cache, not runnable app images | — |
**Not-yet-published** (no workflow produces them; the registry serves zero of
these; re-check with the recipe below before trusting any change here):
| Tag | Status |
|-----|--------|
| `v1.2.3` (semver image tags) | NOT-YET-PUBLISHED — pending the owner's release-authority decision |
| `v1.2` (minor alias) | NOT-YET-PUBLISHED — same |
| `nightly-YYYYMMDD` | NOT-YET-PUBLISHED — no nightly workflow exists |
To find the tags that actually exist right now (anonymous, no login):
```bash
curl -s "https://ghcr.io/token?scope=repository:detain/phlix-server:pull" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])' >/tmp/ghcr.token
curl -s -H "Authorization: Bearer $(cat /tmp/ghcr.token)" \
"https://ghcr.io/v2/detain/phlix-server/tags/list" | python3 -m json.tool
```
Never bake a `<full-sha>` into docs or manifests by hand — query the list and
pick the commit you want.
## Feature compatibility and API versioning
### Feature compatibility
> ⚠ **The per-feature "1.x.0+ / 1.x.0+" floor table that used to sit here
> never matched shipped versions:** pairing, relay tunnels, delegated auth
> and shared libraries all went live while the hub was still on its 0.x
> line (hub `0.5.0` today) against a 1.x server. There is no per-feature
> version floor recorded in either repo. What these features actually
> negotiate is the pinned `@phlix/contracts` wire contract and the
> documented relay envelope — see "Server-hub compatibility" above.
### API versioning
- Server API: `/api/v1/*`
- Hub API: `/api/v1/*`
- Breaking changes increment major version
## Rollback procedures
### Docker image rollback
Rollback targets the immutable `<full-sha>-<variant>` tag of the previous
known-good commit — precise because the current regime publishes one per build
(never `latest`: it is mutable and may already be the broken build). Find the
current tag list with the anonymous recipe in "Docker image tagging" above,
pick the previous run's full SHA, then:
```bash
# Rollback to the previous good build's immutable tag
PREV_SHA=<full-sha-of-last-good-master-build> # from tags/list, see above
docker pull ghcr.io/detain/phlix-server:${PREV_SHA}-latest
# Update deployment
kubectl set image deployment/phlix phlix=ghcr.io/detain/phlix-server:${PREV_SHA}-latest
```
(`-intel` / `-nvidia` for the hardware-accelerated legs. The Helm chart has
**no default `image.tag`** — an unset value fails rendering via `required`
with this exact immutable form named in the error; the chart does not
mechanically reject a hand-set mutable `latest`, but pinning one defeats
rollback, so don't.)
### Database migration rollback
**There is no automated migration rollback.** The commands this section used
to list — `php scripts/run-migrations.php rollback` and
`php scripts/run-migrations.php migrate:down <version>` — exist nowhere in the
codebase: `scripts/run-migrations.php` parses no arguments at all, so typing
them during an incident ran the **forward** migration anyway — the opposite of
what was asked. `bin/phlix migrate` has no `down`/rollback command either, and
`MigrationRunner` has no reverse path. Stray words passed to
`scripts/run-migrations.php` are silently ignored, not guarded against.
`bin/phlix` behaves the opposite way: it runs on symfony/console (pinned
v7.4.21), so a stray extra word after `migrate` errors non-zero — `No
arguments expected for "migrate" command, got "rollback".` — and an unknown
`migrate:down` errors as an undefined command. Loud, but no rollback either.
Real recovery, in the order an incident should reach for it:
1. **Roll back the image and leave the schema alone.** The
`schema_migrations` ledger and all applied DDL live in the database, not in
the image, so a rolled-back image re-runs only its own (older) migration
files — every one of them is already in the ledger and is skipped. Recent
migrations have been additive, dead-index drops (e.g.
`migrations/119_drop_redundant_dead_indexes.sql`), or self-contained
rebuilds whose own headers analyze the old-code impact (e.g.
`migrations/120_collation_unicode_ci_conversion.sql`, a ten-table collation
`CONVERT TO CHARACTER SET` rebuild); read the release's migration SQL to
confirm before relying on this.
2. **Restore from a backup.** Take one before the release with
`php bin/phlix backup:create --label=pre-v1.2.3` and find its id with
`php bin/phlix backup:list`. Restore is admin HTTP API only —
`POST /api/v1/admin/backup/{id}/restore`, behind `AdminMiddleware` (valid
admin JWT); there is no CLI restore command. Each archive carries a
`mysqldump --single-transaction` of all tables, every `*.php` under
`config/` (recursive), and — when those directories exist — the
`data/` tree and SSL certs; the stored SHA-256 checksum is verified before
the import runs. The restore itself re-imports the database dump and the
config files **only**: `BackupManager::restore()` never touches `data/` or SSL
files from the archive, so extract those manually if you need them. Honest
semantics: the dump replaces the tables that existed at backup time
(mysqldump's per-table `DROP TABLE IF EXISTS` + `CREATE TABLE`) but does not
drop tables created afterwards, the `schema_migrations` ledger travels
inside the dump — so once newer code boots, post-backup migrations replay
FORWARD against the restored schema — and the `backups` history table
itself is rolled back too (later archives survive on disk/S3 but vanish
from the list). Pair the restore with the image rollback so the replay
matches the code.
3. **Remediate forward.** The supported fix for a migration that left the
schema in a bad shape is a NEW numbered migration that repairs it — the
ledger records filenames and applies each once, so history is never
rewritten, only followed up.
4. **Manual reversal, where a migration documents one.** Some migrations ship
a `## Reversibility` header with hand-written `ALTER` recipes and a
data-shape safety analysis — `migrations/100_user_item_data_profile_id.sql`
states in those words that its reverse is *"deliberately NOT automated"*.
These are best-effort, human-audited escape hatches to run only against a
verified backup, not a command surface.
## Security releases
For critical security issues:
1. Release patch version within 48 hours
2. No advance notice
3. No beta period
4. Immediate Docker image push
5. Security advisory published
## Hotfix process
For critical bugs in production:
1. Commit the fix directly on `master` (it is always the releasable HEAD)
and test it.
2. Bump the patch version with `./scripts/release.sh patch`.
3. Tag and push both:
```bash
git tag v1.2.1
git push && git push origin v1.2.1
```
(Former revisions of this section prescribed a hotfix branch, `merge --no-ff`
and branch deletion — the same fictional ceremony corrected at step 1 above.)
## Release integrity (signing: NOT SHIPPED)
> **Correction first.** This section used to open with "All release tags and
> Docker images are GPG signed for security" and offered operator commands
> (`git tag -s`, `cosign sign/verify ghcr.io/detain/phlix-server:v1.2.0`).
> None of it was true on any axis, checked at `dd9e54a8`:
> * *Tags* — `git verify-tag` answers `error: no signature found` for every
> release tag this repo has ever published (`v1.0.0`, `v1.2.0`, `v1.2.1`,
> `v1.2.2`, `v1.2.3`); they are all annotated, all unsigned.
> * *Workflows* — no file under `.github/workflows/` contains a GPG, cosign,
> sigstore or attestation step, and no job requests `id-token: write`, the
> OIDC permission keyless (sigstore) signing would require. The signing
> pipeline does not merely not run — it cannot run from these workflows.
> * *Registry* — the OCI referrers API for a published image's digest
> (probe recipe below) returns `MANIFEST_UNKNOWN`: zero signature or
> attestation manifests exist beside any pushed image.
> * *The example itself* — `ghcr.io/detain/phlix-server:v1.2.0` is a tag the
> registry has never served ("Docker image tagging" above); `cosign
> verify` as written could not have resolved anything to verify.
> Publishing signed artifacts is a deliberate owner-gated gap,
> not an oversight the docs may pretend away.
What IS true today, and what it is actually worth:
| Mechanism | Where | Trust you get | Trust you do NOT get |
|-----------|-------|---------------|----------------------|
| TLS transport + registry digest verification when pinning by `@sha256:…` | GHCR, every pull | content integrity of the pulled bytes | publisher identity |
| Per-run base-digest equality gate | `docker.yml` "Verify the base image is the one built from this commit" (hard error, empty/mismatched digest) | runtime image was built against the base image published in the same run | anything about who ran the build |
| Immutable `<full-sha>-<variant>` image tags, derived from the commit | docker.yml tag table ("Docker image tagging" above) | the image bytes trace to a specific commit on this repo's master | the commit was never signed; the GHCR write token does not carry `id-token` |
| GitHub release with the packaged Helm `.tgz` files attached | `release.yml` "Create Release" | assets are served by GitHub over TLS, at the release URL | no published checksum file, no detached signature — hash each download yourself if this matters |
Integrity checks that work right now:
```bash
# 1. Pin by digest. Resolve a tag to its digest (or use the tags/list
# recipe above), then pull the digest form. The pull fails loud on any
# byte mismatch; this is the only cryptographic verification that
# exists today — of CONTENT, not of AUTHORSHIP.
TOK=$(curl -s "https://ghcr.io/token?scope=repository:detain/phlix-server:pull" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
DIG=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $TOK" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
"https://ghcr.io/v2/detain/phlix-server/manifests/<full-sha>-latest" \
| tr -d '\r' | awk -F': ' '/^docker-content-digest/{print $2}')
docker pull "ghcr.io/detain/phlix-server@$DIG"
# 2. Re-derive a manifest digest locally: GHCR returns the uncompressed
# manifest (docker-content-digest == sha256 of the body), so
# GET the manifest and sha256sum the bytes; the two must agree.
# 3. Query whether ANY signature/attestation now exists (a future
# cosign/sigstore implementation would make this answer):
# expect "manifest unknown" until it does.
curl -s -H "Authorization: Bearer $TOK" \
"https://ghcr.io/v2/detain/phlix-server/referrers/$DIG"
```
Do not advertise tags or images as "signed" until the workflows themselves
change. The prerequisites, whichever route the owner picks:
* **Signed tags** — the tagging operator (owner) needs a configured
GPG identity, `git tag -s` at release time, and consumers need the public
key out of band. Retagging published versions is history rewriting; the
cure is forward-only (new tags signed, old ones stay as they are).
* **Keyless cosign (recommended shape)** — add `id-token: write` to the
docker.yml push jobs' permissions and a `cosign sign` (or buildx
provenance + attestation) step after each push; no secret to manage,
signatures recorded in the registry's referrers API, verified with
`cosign verify --certificate-identity-regexp …`.
* **BYOK cosign** — a stored keypair as a repo secret plus the signing
step; more key-management surface, works outside GitHub Actions too.