From d09fcea54c601270a74276532411600bed91f2c4 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 00:13:17 -0400 Subject: [PATCH 1/4] chore: ignore local Scratch directory The Scratch/ tree holds local design templates and experiments (e.g. the Mantis UI template zips) that must never be committed. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 87158d7..9006577 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ coverage/ # Temporary files tmp/ temp/ + +# Local scratch space (design templates, experiments) - never committed +/Scratch/ +scratch/ -- 2.52.0 From 7f824245d333a6b7f6d52405233f01eefbd8732f Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 00:13:35 -0400 Subject: [PATCH 2/4] test: add config loader and health/version smoke tests Cover the environment-driven config loader (defaults, overrides, secret from file) and the unauthenticated /health and /api/v1/version handlers the Docker HEALTHCHECK and bootstrap flow depend on. Co-Authored-By: Claude Opus 4.8 --- backend/internal/config/config_test.go | 100 +++++++++++++++++++++++++ backend/internal/server/health_test.go | 78 +++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/server/health_test.go diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..b979dee --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLoadDefaults verifies that Load falls back to the documented defaults +// when no configuration environment variables are set. +func TestLoadDefaults(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("ORCHESTRAD_DATA_PATH", dataDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("Host = %q, want 0.0.0.0", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("Port = %d, want 8080", cfg.Server.Port) + } + if cfg.Logging.Level != "info" { + t.Errorf("Log level = %q, want info", cfg.Logging.Level) + } + if !cfg.Database.WALMode { + t.Error("WALMode = false, want true") + } + // With no secret set, Load allows startup with an insecure dev key rather + // than failing, so local development works out of the box. + if len(cfg.SecretKey) == 0 { + t.Error("SecretKey is empty; expected an insecure dev fallback") + } + // Load must create the data directory if it does not exist. + if _, err := os.Stat(dataDir); err != nil { + t.Errorf("data dir not present after Load: %v", err) + } +} + +// TestLoadEnvOverrides verifies that environment variables override defaults +// and that CSV-valued settings are parsed into slices. +func TestLoadEnvOverrides(t *testing.T) { + t.Setenv("ORCHESTRAD_DATA_PATH", t.TempDir()) + t.Setenv("ORCHESTRAD_HOST", "127.0.0.1") + t.Setenv("ORCHESTRAD_PORT", "9090") + t.Setenv("ORCHESTRAD_LOG_LEVEL", "debug") + t.Setenv("ORCHESTRAD_SECRET_KEY", "a-real-secret-value") + t.Setenv("ORCHESTRAD_ALLOWED_ORIGINS", "https://a.example, https://b.example") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Server.Host != "127.0.0.1" { + t.Errorf("Host = %q, want 127.0.0.1", cfg.Server.Host) + } + if cfg.Server.Port != 9090 { + t.Errorf("Port = %d, want 9090", cfg.Server.Port) + } + if cfg.Logging.Level != "debug" { + t.Errorf("Log level = %q, want debug", cfg.Logging.Level) + } + if string(cfg.SecretKey) != "a-real-secret-value" { + t.Errorf("SecretKey = %q, want the value from the env", string(cfg.SecretKey)) + } + want := []string{"https://a.example", "https://b.example"} + if len(cfg.CORS.AllowedOrigins) != len(want) { + t.Fatalf("AllowedOrigins = %v, want %v", cfg.CORS.AllowedOrigins, want) + } + for i := range want { + if cfg.CORS.AllowedOrigins[i] != want[i] { + t.Errorf("AllowedOrigins[%d] = %q, want %q", i, cfg.CORS.AllowedOrigins[i], want[i]) + } + } +} + +// TestSecretKeyFromFile verifies that ORCHESTRAD_SECRET_KEY_FILE is read and +// that a trailing newline (as written by `echo` or a mounted secret) is +// stripped. +func TestSecretKeyFromFile(t *testing.T) { + t.Setenv("ORCHESTRAD_DATA_PATH", t.TempDir()) + + secretPath := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(secretPath, []byte("file-secret\n"), 0o600); err != nil { + t.Fatalf("writing secret file: %v", err) + } + t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", secretPath) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if string(cfg.SecretKey) != "file-secret" { + t.Errorf("SecretKey = %q, want %q", string(cfg.SecretKey), "file-secret") + } +} diff --git a/backend/internal/server/health_test.go b/backend/internal/server/health_test.go new file mode 100644 index 0000000..221a801 --- /dev/null +++ b/backend/internal/server/health_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Grace-Solutions/OrchestrAD/internal/version" +) + +// handleHealth and handleVersion are deliberately independent of the Server's +// wired dependencies (db, services, etc.), so a zero-value Server is enough to +// exercise them. These act as a smoke test that the liveness and version +// endpoints keep their documented contract, which the Docker HEALTHCHECK and +// the bootstrap flow both rely on. + +func TestHandleHealth(t *testing.T) { + s := &Server{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + + s.handleHealth(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + + var body struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v (body=%q)", err, rr.Body.String()) + } + if body.Status != "healthy" { + t.Errorf("status = %q, want %q", body.Status, "healthy") + } +} + +func TestHandleVersion(t *testing.T) { + // Pin the build-time vars so the assertion is deterministic regardless of + // whether the test binary was built with -ldflags. + version.Version = "2026.01.02.0304" + version.BuildTime = "2026-01-02T03:04:05Z" + version.GitCommit = "abcdef1" + + s := &Server{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + + s.handleVersion(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + var body struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + GitCommit string `json:"git_commit"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v (body=%q)", err, rr.Body.String()) + } + if body.Version != version.Version { + t.Errorf("version = %q, want %q", body.Version, version.Version) + } + if body.GitCommit != version.GitCommit { + t.Errorf("git_commit = %q, want %q", body.GitCommit, version.GitCommit) + } + if !strings.Contains(rr.Body.String(), version.BuildTime) { + t.Errorf("body %q missing build_time %q", rr.Body.String(), version.BuildTime) + } +} -- 2.52.0 From 23e70d12a6a5c5cd87d00850d35d7a2325676892 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 00:13:36 -0400 Subject: [PATCH 3/4] build(docker): compile and embed the web UI in a multi-stage image The previous Dockerfile used Go 1.22 (too old for the go 1.24 module) and never built the frontend, so the image shipped only the placeholder UI. Rework it into three stages: build the Next.js static export, stage it into the //go:embed dist dir and compile the CGO binary, then ship a minimal Alpine runtime. .npmrc is copied before npm ci so legacy-peer-deps applies. Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index ffa3d28..9555873 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,45 @@ # OrchestrAD Docker Image -# Multi-stage build for minimal final image +# Three-stage build: (1) compile the Next.js static export, (2) embed it into +# the Go binary, (3) ship a minimal Alpine runtime. The single resulting binary +# serves the full product UI plus the API on one port. -# Build stage -FROM golang:1.22-alpine AS builder +# --------------------------------------------------------------------------- +# Stage 1: build the frontend (Next.js static export -> frontend/out) +# Debian base avoids musl/sharp native-module friction; this stage is discarded. +# --------------------------------------------------------------------------- +FROM node:22-bookworm-slim AS frontend +WORKDIR /frontend + +# Install deps first for better layer caching. .npmrc carries +# legacy-peer-deps=true, which npm ci needs to resolve the React 19 RC peer +# graph, so it must be present before the install runs. +COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./ +RUN npm ci --no-audit --no-fund + +COPY frontend/ ./ +RUN npm run build +# next.config.mjs sets output:"export", so the static site lands in ./out + +# --------------------------------------------------------------------------- +# Stage 2: build the Go binary with the UI embedded +# --------------------------------------------------------------------------- +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git gcc musl-dev WORKDIR /build -# Copy go.mod first for better caching +# Cache modules first. COPY backend/go.mod backend/go.sum* ./ RUN go mod download -# Copy source +# Copy source, then drop the staged UI into the embed directory. webui.go does +# //go:embed all:dist, so the compiled binary carries the real UI instead of +# the placeholder page. COPY backend/ ./ +COPY --from=frontend /frontend/out/ ./internal/webui/dist/ -# Build with version info +# Build with version info injected via ldflags. ARG VERSION=dev ARG BUILD_TIME=unknown ARG GIT_COMMIT=unknown @@ -27,10 +51,12 @@ RUN CGO_ENABLED=1 go build \ -X github.com/Grace-Solutions/OrchestrAD/internal/version.GitCommit=${GIT_COMMIT}" \ -o /orchestrad ./cmd/orchestrad -# Final stage +# --------------------------------------------------------------------------- +# Stage 3: minimal runtime +# --------------------------------------------------------------------------- FROM alpine:3.19 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata wget # Create non-root user RUN addgroup -g 1000 orchestrad && \ -- 2.52.0 From 010add46425f96d5839fb7c6345c5b7c5d86106e Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 00:13:38 -0400 Subject: [PATCH 4/4] ci: add release + container image pipeline on merge to main Single Gitea Actions workflow triggered only by a push to main (no per-commit or per-PR CI). It runs the Go test suite as a gate, then builds and pushes the container image tagged latest and the unified HEAD-commit date (yyyy.MM.dd.HHmm), and cuts a Gitea release with the linux/amd64 binary attached. Defaults to the built-in Gitea registry; REGISTRY_* secrets override to an external one. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/release.yml | 218 +++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..f6b5a26 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,218 @@ +name: Release + +# Fires only on a merge into main (a push to the main branch). No per-commit +# or per-PR CI runs on other branches — this is the single pipeline that turns +# what lands on main into a release + container image. +# +# Docs-only merges are skipped: touching just README/docs/LICENSE does not +# produce a new build. +on: + # Allow an on-demand run from the Gitea Actions UI/API without a dummy commit. + workflow_dispatch: + push: + branches: [main] + paths-ignore: + - 'README.md' + - 'LICENSE' + - 'docs/**' + - '.gitignore' + - '.gitattributes' + +jobs: + release: + # Label must match a registered Linux runner that has Docker (used for the + # dockerized test gate and the image build/push). This is the same label the + # sibling repos use for their image jobs. + runs-on: ubuntu-host + + env: + # Optional EXTERNAL registry override. Leave these unset to publish to the + # Gitea instance's own built-in container registry (the default below). + REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }} + REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASS: ${{ secrets.REGISTRY_PASSWORD }} + # Injected automatically by Gitea Actions; used for the built-in registry + # login and for creating the release. No manual secret needed. + BUILTIN_TOKEN: ${{ secrets.GITEA_TOKEN }} + SERVER_URL: ${{ github.server_url }} + ACTOR: ${{ github.actor }} + OWNER: ${{ github.repository_owner }} + + steps: + # OrchestrAD is a SHA-1 repo, so actions/checkout works as-is. fetch-depth + # 0 is required so the HEAD commit date is available for the version. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Version = the UTC date of the HEAD commit, formatted yyyy.MM.dd.HHmm + # (the project's documented version scheme). Deriving it from the commit + # rather than "now" makes re-runs reproducible and keeps the image tag, + # the release tag, and the binary's embedded version identical. + - name: Compute version + id: ver + shell: bash + run: | + set -euo pipefail + VERSION="$(TZ=UTC git show -s --format=%cd --date=format-local:'%Y.%m.%d.%H%M' HEAD)" + GIT_COMMIT="$(git rev-parse HEAD)" + GIT_COMMIT_SHORT="$(git rev-parse --short HEAD)" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + { + echo "version=$VERSION" + echo "git_commit=$GIT_COMMIT" + echo "git_commit_short=$GIT_COMMIT_SHORT" + echo "build_time=$BUILD_TIME" + } >> "$GITHUB_OUTPUT" + echo "OrchestrAD version: $VERSION ($GIT_COMMIT_SHORT)" + + # Test gate: run the Go test suite inside the same toolchain image the + # build uses. Running it in a container means the runner needs only Docker + # (no host Go/gcc), and a failure here stops the release before anything + # is published. CGO is on because the SQLite driver requires it. + - name: Test + shell: bash + run: | + set -euo pipefail + docker run --rm -v "$PWD/backend:/src" -w /src \ + -e CGO_ENABLED=1 golang:1.24-alpine \ + sh -c "apk add --no-cache gcc musl-dev >/dev/null && go test ./..." + + # jq is used to JSON-encode the release body safely. Install it if the + # self-hosted runner does not already have it. + - name: Ensure tooling (jq) + shell: bash + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "Installing jq..." + sudo apt-get update -y && sudo apt-get install -y jq + fi + jq --version + + - name: Resolve registry target + id: reg + shell: bash + run: | + set -euo pipefail + if [ -n "${REGISTRY_HOST:-}" ]; then + HOST="$REGISTRY_HOST"; USER="$REGISTRY_USER"; PASS="$REGISTRY_PASS" + echo "Using external registry $HOST" + else + HOST="$(echo "$SERVER_URL" | sed -E 's#^https?://##; s#/$##')" + USER="$ACTOR"; PASS="$BUILTIN_TOKEN" + echo "Using the built-in Gitea registry at $HOST" + fi + OWNER_LC="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')" + { + echo "host=$HOST" + echo "user=$USER" + echo "image=${HOST}/${OWNER_LC}/orchestrad" + } >> "$GITHUB_OUTPUT" + echo "::add-mask::$PASS" + echo "REGISTRY_LOGIN_PASSWORD=$PASS" >> "$GITHUB_ENV" + + - name: Registry login + run: echo "$REGISTRY_LOGIN_PASSWORD" | docker login "${{ steps.reg.outputs.host }}" -u "${{ steps.reg.outputs.user }}" --password-stdin + + # One multi-stage build compiles the Next.js UI, embeds it, and produces + # the Go binary. Tag both the immutable version and latest (main only). + - name: Build image + env: + IMAGE: ${{ steps.reg.outputs.image }} + VERSION: ${{ steps.ver.outputs.version }} + GIT_COMMIT: ${{ steps.ver.outputs.git_commit }} + BUILD_TIME: ${{ steps.ver.outputs.build_time }} + run: | + set -euo pipefail + docker build \ + --build-arg VERSION="$VERSION" \ + --build-arg GIT_COMMIT="$GIT_COMMIT" \ + --build-arg BUILD_TIME="$BUILD_TIME" \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:latest" \ + . + + - name: Push image + env: + IMAGE: ${{ steps.reg.outputs.image }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + docker push "${IMAGE}:${VERSION}" + docker push "${IMAGE}:latest" + echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest" + + # Pull the linux/amd64 binary back out of the freshly built image so the + # release carries a ready-to-run artifact, not just an image reference. + - name: Extract release binary + env: + IMAGE: ${{ steps.reg.outputs.image }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + CID="$(docker create "${IMAGE}:${VERSION}")" + docker cp "$CID:/app/orchestrad" "dist/orchestrad" + docker rm "$CID" >/dev/null + tar -C dist -czf "dist/orchestrad-${VERSION}-linux-amd64.tar.gz" orchestrad + ( cd dist && sha256sum "orchestrad-${VERSION}-linux-amd64.tar.gz" > "orchestrad-${VERSION}-linux-amd64.tar.gz.sha256" ) + ls -la dist + + # Create the Gitea release for this version and attach the binary. Uses + # the auto-injected token; no manual secret required. + - name: Create Gitea release + shell: bash + env: + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + GIT_COMMIT: ${{ steps.ver.outputs.git_commit }} + GIT_COMMIT_SHORT: ${{ steps.ver.outputs.git_commit_short }} + IMAGE: ${{ steps.reg.outputs.image }} + run: | + set -euo pipefail + + # Skip if a release for this tag already exists (e.g. a re-run). + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${API_URL}/repos/${REPO}/releases/tags/${VERSION}")" + if [ "$code" = "200" ]; then + echo "Release ${VERSION} already exists; skipping." + exit 0 + fi + + # Markdown release notes, JSON-encoded with jq so any characters are + # safely escaped. + body="$(printf '**OrchestrAD %s**\n\n| Field | Value |\n| --- | --- |\n| Version | `%s` |\n| Commit | [`%s`](%s/%s/commit/%s) |\n\n## Container image\n```\ndocker pull %s:%s\ndocker pull %s:latest\n```\n' \ + "$VERSION" "$VERSION" "$GIT_COMMIT_SHORT" "$SERVER_URL" "$REPO" "$GIT_COMMIT" "$IMAGE" "$VERSION" "$IMAGE")" + + payload="$(jq -n \ + --arg tag "$VERSION" \ + --arg sha "$GIT_COMMIT" \ + --arg name "OrchestrAD $VERSION" \ + --arg body "$body" \ + '{tag_name:$tag, target_commitish:$sha, name:$name, body:$body, draft:false, prerelease:false}')" + + rel="$(curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${API_URL}/repos/${REPO}/releases")" + rel_id="$(printf '%s' "$rel" | jq -r '.id')" + if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then + echo "Failed to create release:"; echo "$rel"; exit 1 + fi + echo "Created release id=$rel_id" + + for asset in dist/orchestrad-${VERSION}-linux-amd64.tar.gz dist/orchestrad-${VERSION}-linux-amd64.tar.gz.sha256; do + name="$(basename "$asset")" + echo "Uploading $name" + curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${asset}" \ + "${API_URL}/repos/${REPO}/releases/${rel_id}/assets?name=${name}" >/dev/null + done + echo "Release ${VERSION} published." -- 2.52.0