test: gate unified compose deployment
This commit is contained in:
@@ -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"
|
||||
@@ -3,6 +3,35 @@
|
||||
> 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.
|
||||
|
||||
## 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
|
||||
|
||||
- **Mandatory stack.** The supported Compose stack is exactly `frontend` plus `core`; use the
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The local-vector overlay reads `THT_VECTOR_BOOTSTRAP_PASSWORD`,
|
||||
|
||||
@@ -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."
|
||||
Executable
+8
@@ -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
|
||||
Executable
+1031
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user