Fix build/packaging + add release & container pipeline #1
@@ -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."
|
||||
@@ -65,3 +65,7 @@ coverage/
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Local scratch space (design templates, experiments) - never committed
|
||||
/Scratch/
|
||||
scratch/
|
||||
|
||||
+34
-8
@@ -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 && \
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user