test: gate unified compose deployment

This commit is contained in:
2026-08-05 13:41:33 +02:00
parent b44a1b9ad9
commit 2ae89c075e
6 changed files with 1313 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
name: Deployment release gate
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deployment-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deterministic:
name: LF, Compose, docs, and TypeScript
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.16.0"
package-manager-cache: false
- name: Verify shell syntax and LF policy
run: |
git ls-files -z '*.sh' | xargs -0 -n1 bash -n
bash scripts/verify-line-endings.sh
- name: Verify Compose and installation contracts
run: |
bash scripts/test-unified-compose.sh
bash scripts/test-compose-secret-policy.sh
bash scripts/test-no-deployment-coupling.sh
bash scripts/test-verify-workspace-install-docs.sh
bash scripts/unified-deployment-smoke.sh --self-test
git diff --check
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Test and type-check backend
working-directory: backend
run: |
npx vitest run
npx tsc --noEmit -p .
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Test and type-check frontend
working-directory: frontend
run: |
npx vitest run
npx tsc -b
linux-docker:
name: Linux Docker deployment and rollback
runs-on: ubuntu-24.04
timeout-minutes: 70
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run unified deployment smoke
run: timeout --signal=TERM --kill-after=45s 30m bash scripts/unified-deployment-smoke.sh
- name: Run thothctl update smoke
run: timeout --signal=TERM --kill-after=45s 30m bash scripts/thothctl-update-smoke.sh
windows-clone:
name: Windows clone and Compose contract
runs-on: windows-2025
timeout-minutes: 20
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.5"
cache-dependency-path: tools/thothctl/go.sum
- name: Build native Windows thothctl
working-directory: tools/thothctl
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path ../../dist/thothctl | Out-Null
go build -trimpath -o ../../dist/thothctl/thothctl-windows-amd64.exe ./cmd/thothctl
- name: Verify Windows clone contract
shell: pwsh
run: >-
./scripts/test-windows-clone-contract.ps1
-ThothctlPath "$PWD/dist/thothctl/thothctl-windows-amd64.exe"
+29
View File
@@ -3,6 +3,35 @@
> Starting-point snapshot for new sessions. Last updated: 2026-08-05 (portable deployment decoupled). > Starting-point snapshot for new sessions. Last updated: 2026-08-05 (portable deployment decoupled).
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work. > Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
## Unified deployment release gate — Task 13 (2026-08-05)
- **Release coverage.** `scripts/unified-deployment-smoke.sh` gates the two-service render/build,
frontend-to-core routing, embedded pinned Pi, Git registry bootstrap, offline recreation, valid
update, invalid-update retention, and the four persistent stores. `scripts/thothctl-update-smoke.sh`
independently exercises the bad-Pi update and automatic rollback path.
- **Isolation and disclosure boundary.** Every run generates a unique temporary root, Compose
project, container/image names, transaction image tags, and run label. The rollback fixture uses
an immutable public digest as a deliberately dead core rather than a host-local image registry.
Cleanup checks ownership before removing exact containers, Compose resources, image references,
control state, and temporary files. There is no global prune. Failure diagnostics are bounded
and sanitized, and all credentials/endpoints used by the smokes are disposable fixtures rather
than operator or repository secrets.
- **Cross-platform CI contract.** `.github/workflows/deployment.yml` uses immutable action commits,
pinned supported Node and Go versions, runs LF/Compose/secret/coupling/docs/TypeScript gates on
Linux, runs each Docker smoke once under its own outer timeout, and builds/invokes native Windows
`thothctl` after the PowerShell clone/LF/Compose contract. Native Windows execution remains an
explicit manual release gate in addition to CI; no Windows Docker container startup is claimed
by the static clone job.
- **Validation status.** Deterministic Phase A gates, backend **434/434** plus TypeScript,
frontend **386/386** plus TypeScript, and harness **862 passed / 5 L2 deselected** are green.
The unified one-shot Docker run passed frontend/core/internal Pi, registry bootstrap, offline
recreation, valid update, invalid-update retention, and persistence before Docker Desktop
refused the daemon-to-host local-registry push; the update-only run reached the same boundary.
Both exact run/project resource sets were independently proved absent. The local-registry
fixture was then removed in favor of the immutable dead-core digest, but the requested no-retry
rule leaves automatic rollback/preservation pending in CI or a fresh manual release run. Native
Windows PowerShell execution is also still a manual release gate.
## Portable deployment decoupling — LIVE 2026-08-05 ## Portable deployment decoupling — LIVE 2026-08-05
- **Mandatory stack.** The supported Compose stack is exactly `frontend` plus `core`; use the - **Mandatory stack.** The supported Compose stack is exactly `frontend` plus `core`; use the
+39
View File
@@ -89,6 +89,45 @@ volume afterward. It never targets the fixed `thothii` operator project or its v
`KEEP_SMOKE_RESOURCES=1` to retain that smoke project's resources for inspection; remove them `KEEP_SMOKE_RESOURCES=1` to retain that smoke project's resources for inspection; remove them
later with `docker compose --project-name "$SMOKE_PROJECT" down --volumes`. later with `docker compose --project-name "$SMOKE_PROJECT" down --volumes`.
## Unified deployment release gates
Task 13 adds a no-secret release gate around the canonical base plus local Compose profile. Its
deterministic safety check does not contact the Docker daemon:
```sh
bash scripts/unified-deployment-smoke.sh --self-test
```
The two Docker smokes are separate release jobs. Each creates a unique Compose project, temporary
Git workspace remote, fixture provider, image names, and run label. Its exit trap removes only
resources carrying that exact run identity and never performs a global Docker prune.
```sh
bash scripts/unified-deployment-smoke.sh
bash scripts/thothctl-update-smoke.sh
```
The unified smoke builds and starts `frontend` and `core`, verifies the embedded Pi and internal
registry, recreates with the Git remote offline, activates a valid Git update, rejects invalid Git
content while retaining the valid snapshot, and checks the four persistence volumes. Both smokes
inject a digest-pinned non-core candidate under a deliberately mismatched Pi version and require
`thothctl pi update` to roll back while preserving settings, sessions, Pi state, registry revision,
and mount identity. Fixture credentials are generated locally; neither command needs a real
provider key or a repository secret. CI gives each smoke one 30-minute outer timeout and does not
retry it.
On a native Windows clone, the release contract is:
```powershell
.\scripts\test-windows-clone-contract.ps1 `
-ThothctlPath "$PWD\dist\thothctl\thothctl-windows-amd64.exe"
```
It checks Git's CRLF/LF attributes and bytes, renders exactly `core` plus `frontend` with Docker
Compose without starting containers, and invokes the native Windows `thothctl`. The GitHub Actions
deployment workflow runs the deterministic Linux gates, both bounded Docker smokes, and this
Windows clone contract with immutable action pins and supported pinned Node/Go toolchains.
## Optional local pgvector and recovery ## Optional local pgvector and recovery
The local-vector overlay reads `THT_VECTOR_BOOTSTRAP_PASSWORD`, The local-vector overlay reads `THT_VECTOR_BOOTSTRAP_PASSWORD`,
+108
View File
@@ -0,0 +1,108 @@
param(
[string]$RepositoryRoot = "",
[string]$ThothctlPath = ""
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
$RepositoryRoot = (& git rev-parse --show-toplevel).Trim()
if ($LASTEXITCODE -ne 0) {
throw "git could not resolve the repository root"
}
}
$RepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot)
$tracked = @(& git -C $RepositoryRoot ls-files)
if ($LASTEXITCODE -ne 0) {
throw "git ls-files failed"
}
$scriptRelativePath = "scripts/test-windows-clone-contract.ps1"
$eolAttribute = (& git -C $RepositoryRoot check-attr eol -- $scriptRelativePath).Trim()
if ($LASTEXITCODE -ne 0 -or -not $eolAttribute.EndsWith("eol: crlf", [System.StringComparison]::OrdinalIgnoreCase)) {
throw "the Windows contract script must have the repository eol=crlf attribute"
}
$scriptBytes = [System.IO.File]::ReadAllBytes((Join-Path $RepositoryRoot $scriptRelativePath))
if (-not ($scriptBytes -contains [byte]0x0D)) {
throw "the Windows contract script was not checked out with CRLF bytes"
}
$offenders = [System.Collections.Generic.List[string]]::new()
foreach ($relativePath in $tracked) {
$name = [System.IO.Path]::GetFileName($relativePath)
$mustBeLf = $relativePath.EndsWith(".sh", [System.StringComparison]::OrdinalIgnoreCase) -or
$relativePath.EndsWith(".yml", [System.StringComparison]::OrdinalIgnoreCase) -or
$relativePath.EndsWith(".yaml", [System.StringComparison]::OrdinalIgnoreCase) -or
$name.Equals("Dockerfile", [System.StringComparison]::OrdinalIgnoreCase) -or
$name.StartsWith("Dockerfile.", [System.StringComparison]::OrdinalIgnoreCase) -or
$name.EndsWith(".Dockerfile", [System.StringComparison]::OrdinalIgnoreCase)
if (-not $mustBeLf) {
continue
}
$absolutePath = Join-Path $RepositoryRoot $relativePath
$bytes = [System.IO.File]::ReadAllBytes($absolutePath)
if ($bytes -contains [byte]0x0D) {
$offenders.Add($relativePath)
}
}
if ($offenders.Count -ne 0) {
throw "CR byte 0x0D found in tracked LF contract files: $($offenders -join ', ')"
}
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("thothii-windows-contract-" + [guid]::NewGuid().ToString("N"))
$savedEnvironment = @{
THT_WORKSPACE_GIT_REMOTE = $env:THT_WORKSPACE_GIT_REMOTE
PI_AUTH_FILE = $env:PI_AUTH_FILE
THT_SECRETS_FILE = $env:THT_SECRETS_FILE
}
try {
[System.IO.Directory]::CreateDirectory($temporaryRoot) | Out-Null
$piAuth = Join-Path $temporaryRoot "pi-auth.json"
$secrets = Join-Path $temporaryRoot "thothii.secrets"
[System.IO.File]::WriteAllText($piAuth, "{}`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($secrets, "THT_MODEL_API_KEY=windows-contract`n", [System.Text.UTF8Encoding]::new($false))
$env:THT_WORKSPACE_GIT_REMOTE = "https://git.example.invalid/platform/thoth-workspaces.git"
$env:PI_AUTH_FILE = $piAuth
$env:THT_SECRETS_FILE = $secrets
$composeFiles = @(
"--project-directory", $RepositoryRoot,
"-f", (Join-Path $RepositoryRoot "compose.yaml"),
"-f", (Join-Path $RepositoryRoot "deploy/compose.local.yaml")
)
$services = @(& docker compose @composeFiles config --services)
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose could not render the Windows clone"
}
if ((($services | Sort-Object) -join ",") -ne "core,frontend") {
throw "rendered Windows stack must contain exactly core and frontend"
}
& docker compose @composeFiles config --quiet
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose rejected the Windows clone"
}
if ([string]::IsNullOrWhiteSpace($ThothctlPath)) {
$ThothctlPath = Join-Path $RepositoryRoot "dist/thothctl/thothctl-windows-amd64.exe"
}
$ThothctlPath = [System.IO.Path]::GetFullPath($ThothctlPath)
if (-not [System.IO.File]::Exists($ThothctlPath)) {
throw "Windows thothctl binary is missing: $ThothctlPath"
}
& $ThothctlPath --help | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Windows thothctl invocation failed"
}
}
finally {
foreach ($name in $savedEnvironment.Keys) {
[System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process")
}
if ([System.IO.Directory]::Exists($temporaryRoot)) {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
}
}
Write-Output "Windows clone, LF-byte, Compose render, and thothctl invocation contracts passed."
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd -P)"
# shellcheck source=./unified-deployment-smoke.sh
source "$root/scripts/unified-deployment-smoke.sh"
task13_smoke_main update
+1031
View File
File diff suppressed because it is too large Load Diff