feat: add source-mounted sf server self-deploy
Some checks are pending
sf self-deploy / build, test, and publish server image (push) Waiting to run
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions

This commit is contained in:
Mikael Hugo 2026-05-17 22:00:01 +02:00
parent 55a498603f
commit c26de39afa
17 changed files with 884 additions and 0 deletions

View file

@ -7,6 +7,9 @@ coverage/
# ── Dependencies ──
node_modules/
packages/*/node_modules/
web/node_modules/
web/.next/
.next/
# ── Environment & secrets ──
.env
@ -37,6 +40,7 @@ Thumbs.db
*~
tmp/
.cache/
.gitignore
# ── Native build artifacts ──
native/

View file

@ -0,0 +1,136 @@
name: sf self-deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
NODE_VERSION: "26.1.0"
SF_SERVER_PORT: "4000"
SF_REGISTRY: ${{ secrets.SF_REGISTRY }}
SF_REGISTRY_USER: ${{ secrets.SF_REGISTRY_USER }}
SF_REGISTRY_PASSWORD: ${{ secrets.SF_REGISTRY_PASSWORD }}
SF_IMAGE_REPOSITORY: ${{ secrets.SF_IMAGE_REPOSITORY }}
SF_PUSH_IMAGE: ${{ vars.SF_PUSH_IMAGE }}
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
SF_TEST_URL: ${{ vars.SF_TEST_URL }}
SF_PROD_URL: ${{ vars.SF_PROD_URL }}
SF_TEST_NAMESPACE: ${{ vars.SF_TEST_NAMESPACE }}
SF_PROD_NAMESPACE: ${{ vars.SF_PROD_NAMESPACE }}
SF_TEST_DEPLOYMENT: ${{ vars.SF_TEST_DEPLOYMENT }}
SF_PROD_DEPLOYMENT: ${{ vars.SF_PROD_DEPLOYMENT }}
jobs:
build:
name: build, test, and publish server image
runs-on: docker
outputs:
image: ${{ steps.image.outputs.image }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: |
npm ci
npm --prefix web ci
- name: Build core
run: npm run build:core
- name: Build web host
run: npm run build:web-host
- name: Typecheck extensions
run: npm run typecheck:extensions
- name: Unit tests
run: npm run test:unit
- name: Resolve image name
id: image
shell: bash
run: |
set -euo pipefail
repo="${SF_IMAGE_REPOSITORY:-${SF_REGISTRY:-registry.centralcloud.com}/singularity/sf-server}"
tag="${GITHUB_SHA:-${CI_COMMIT_SHA:-$(git rev-parse HEAD)}}"
echo "image=${repo}:${tag}" >> "$GITHUB_OUTPUT"
echo "SF_RELEASE_IMAGE=${repo}:${tag}" >> "$GITHUB_ENV"
echo "SF_IMAGE_REPOSITORY=${repo}" >> "$GITHUB_ENV"
- name: Generate release manifest
run: npm run release:manifest -- --out dist/sf-release-manifest.json
- name: Login to registry
if: env.SF_REGISTRY_USER != '' && env.SF_REGISTRY_PASSWORD != ''
run: |
printf '%s' "$SF_REGISTRY_PASSWORD" | docker login "${SF_REGISTRY:-registry.centralcloud.com}" --username "$SF_REGISTRY_USER" --password-stdin
- name: Build server image
run: |
docker build \
-f docker/Dockerfile.sf-server \
--build-arg "SF_GIT_SHA=${GITHUB_SHA:-$(git rev-parse HEAD)}" \
--build-arg "SF_GIT_REF=${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}" \
--build-arg "SF_RELEASE_IMAGE=${{ steps.image.outputs.image }}" \
--build-arg "SF_IMAGE_REPOSITORY=${SF_IMAGE_REPOSITORY:-${SF_REGISTRY:-registry.centralcloud.com}/singularity/sf-server}" \
-t "${{ steps.image.outputs.image }}" \
.
- name: Push server image
if: env.SF_PUSH_IMAGE != '0'
run: docker push "${{ steps.image.outputs.image }}"
deploy-test:
name: deploy test and probe
needs: build
runs-on: docker
if: env.KUBECONFIG_B64 != '' && env.SF_TEST_URL != ''
steps:
- name: Configure kubeconfig
run: |
mkdir -p "$HOME/.kube"
printf '%s' "$KUBECONFIG_B64" | base64 -d > "$HOME/.kube/config"
- name: Roll test deployment
run: |
kubectl -n "${SF_TEST_NAMESPACE:-sf}" set image "deployment/${SF_TEST_DEPLOYMENT:-sf-server-test}" "sf-server=${{ needs.build.outputs.image }}"
kubectl -n "${SF_TEST_NAMESPACE:-sf}" rollout status "deployment/${SF_TEST_DEPLOYMENT:-sf-server-test}" --timeout=180s
- name: Probe test server
run: |
curl --fail --silent --show-error "$SF_TEST_URL/api/healthz"
curl --fail --silent --show-error "$SF_TEST_URL/api/ready"
curl --fail --silent --show-error "$SF_TEST_URL/api/version"
deploy-prod:
name: promote prod
needs:
- build
- deploy-test
runs-on: docker
if: needs.deploy-test.result == 'success' && env.KUBECONFIG_B64 != '' && env.SF_PROD_URL != ''
steps:
- name: Configure kubeconfig
run: |
mkdir -p "$HOME/.kube"
printf '%s' "$KUBECONFIG_B64" | base64 -d > "$HOME/.kube/config"
- name: Roll prod deployment
run: |
kubectl -n "${SF_PROD_NAMESPACE:-sf}" set image "deployment/${SF_PROD_DEPLOYMENT:-sf-server}" "sf-server=${{ needs.build.outputs.image }}"
kubectl -n "${SF_PROD_NAMESPACE:-sf}" rollout status "deployment/${SF_PROD_DEPLOYMENT:-sf-server}" --timeout=180s
- name: Probe prod server
run: |
curl --fail --silent --show-error "$SF_PROD_URL/api/healthz"
curl --fail --silent --show-error "$SF_PROD_URL/api/ready"
curl --fail --silent --show-error "$SF_PROD_URL/api/version"

View file

@ -119,6 +119,13 @@ second writer. Server-forwarded feedback writes are queued and drained by the
server before autonomous dispatch, so CLI control does not block behind a busy
unit.
Self-deploy is defined in [`docs/specs/sf-self-deploy.md`](docs/specs/sf-self-deploy.md).
Forgejo builds a source-pinned server image, writes
`dist/sf-release-manifest.json`, rolls test first, probes `/api/healthz`,
`/api/ready`, and `/api/version`, then promotes the same image to prod.
On vega, use `npm run docker:vega:up` for the containerized source-mounted
server on port 4000.
## Coding Style & Naming Conventions
- **Language**: TypeScript with `"strict": true` enabled in all packages

View file

@ -129,3 +129,10 @@ npm run build:core
Then restart the server. Stale `dist/` or stale `~/.sf/agent/extensions/sf/`
copies can make fixed source look broken.
Self-deploy is documented in `docs/specs/sf-self-deploy.md`. The short version:
Forgejo builds the source-pinned server image, generates
`dist/sf-release-manifest.json`, rolls test, probes `/api/healthz`,
`/api/ready`, and `/api/version`, then promotes the same image to prod.
On vega, use `npm run docker:vega:up` to run the source-mounted container on
port 4000.

View file

@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1.7
#
# Source-built SF server image for Forgejo self-deploy.
#
# Purpose: package the exact repository revision Forgejo verified, including
# the staged Next.js standalone host and release manifest, instead of installing
# a mutable npm tag at runtime.
#
# Consumer: .forgejo/workflows/self-deploy.yml and GitOps deployments that run
# `sf server /workspace --host 0.0.0.0 --port 4000`.
FROM node:26.1-slim AS build
WORKDIR /src
ENV CI=1
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
libsecret-1-dev \
make \
g++ \
python3 \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY packages ./packages
COPY web/package.json web/package-lock.json ./web/
RUN npm ci && npm --prefix web ci
COPY . .
ARG SF_GIT_SHA
ARG SF_GIT_REF
ARG SF_RELEASE_IMAGE
ARG SF_RELEASE_IMAGE_DIGEST
ARG SF_IMAGE_REPOSITORY
ENV SF_GIT_SHA=${SF_GIT_SHA}
ENV SF_GIT_REF=${SF_GIT_REF}
ENV SF_RELEASE_IMAGE=${SF_RELEASE_IMAGE}
ENV SF_RELEASE_IMAGE_DIGEST=${SF_RELEASE_IMAGE_DIGEST}
ENV SF_IMAGE_REPOSITORY=${SF_IMAGE_REPOSITORY}
RUN npm run build:core
RUN npm run build:web-host
RUN npm run release:manifest -- --out dist/sf-release-manifest.json
FROM node:26.1-slim AS sf-server
WORKDIR /opt/sf
ENV NODE_ENV=production
ENV SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json
ENV SF_WEB_PREFER_SOURCE=0
ENV SF_WEB_HOST=0.0.0.0
ENV SF_WEB_PORT=4000
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
libsecret-1-0 \
tini \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src /opt/sf
WORKDIR /workspace
EXPOSE 4000
ENTRYPOINT ["tini", "--", "node", "/opt/sf/dist/loader.js"]
CMD ["server", "/workspace", "--host", "0.0.0.0", "--port", "4000"]

View file

@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1.7
#
# Local source-mounted SF server image.
#
# Purpose: run the vega development/production server inside a container while
# keeping /home/mhugo/code/singularity-forge as the source of truth.
#
# Consumer: docker/docker-compose.vega.yaml.
FROM node:26.1-slim
ENV NODE_ENV=development
ENV HOME=/home/node
ENV SF_WEB_PREFER_SOURCE=0
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
libsecret-1-0 \
tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/sf
EXPOSE 4000
ENTRYPOINT ["tini", "--"]
CMD ["npm", "run", "sf:server", "--", "--host", "0.0.0.0", "--port", "4000"]

View file

@ -0,0 +1,40 @@
services:
sf-server:
build:
context: ..
dockerfile: docker/Dockerfile.source-server
container_name: sf-server-vega
working_dir: /opt/sf
user: "${PUID:-1000}:${PGID:-1000}"
ports:
- "${SF_VEGA_BIND:-127.0.0.1}:4000:4000"
volumes:
- ../:/opt/sf
- ${SF_WORKSPACE_DIR:-..}:/workspace
- ${HOME}/.sf:/home/node/.sf
- ${HOME}/.gitconfig:/home/node/.gitconfig:ro
environment:
HOME: /home/node
NODE_ENV: development
SF_SOURCE_ROOT: /opt/sf
SF_RUNTIME_SOURCE_ROOT: /opt/sf
SF_RELEASE_MANIFEST: /opt/sf/dist/sf-release-manifest.json
SF_WEB_PROJECT_CWD: /workspace
SF_WEB_HOST: 0.0.0.0
SF_WEB_PORT: "4000"
HOSTNAME: 0.0.0.0
PORT: "4000"
SF_WEB_ALLOWED_ORIGINS: ${SF_WEB_ALLOWED_ORIGINS:-http://127.0.0.1:4000,http://localhost:4000}
SF_DEV_SERVER_WATCH: "1"
command:
- node
- /opt/sf/dist/web/standalone/server.js
restart: unless-stopped
healthcheck:
test:
- CMD-SHELL
- node -e "fetch('http://127.0.0.1:4000/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 15s
timeout: 5s
retries: 12
start_period: 60s

View file

@ -39,4 +39,5 @@ memory, ledgers, replay, or drift analysis.
## See also
- [SF operating model export](./sf-operating-model.md)
- [SF self-deploy contract](./sf-self-deploy.md)
- [AGENTS.md#sf-planning-state](../../AGENTS.md#sf-planning-state)

View file

@ -0,0 +1,109 @@
# SF Self-Deploy Contract
SF deploys as a long-running server owned by the deployment platform, not by an
interactive TUI session. Forgejo is the build authority: it verifies a source
revision, publishes an immutable OCI image, then rolls a test server before prod.
## Purpose
The server must be reloadable without humans killing old processes by hand, and
the CLI/web surfaces must be able to prove which build they are controlling. The
artifact boundary is therefore:
1. source revision in git
2. Forgejo build/test result
3. OCI image tag or digest
4. `dist/sf-release-manifest.json`
5. `/api/healthz`, `/api/ready`, and `/api/version` probes
## Build Authority
Forgejo runs `.forgejo/workflows/self-deploy.yml` on `main` and manual dispatch.
The required gates are:
- `npm ci`
- `npm --prefix web ci`
- `npm run build:core`
- `npm run build:web-host`
- `npm run typecheck:extensions`
- `npm run test:unit`
- build `docker/Dockerfile.sf-server`
- generate `dist/sf-release-manifest.json`
The image builder can be Docker, BuildKit, Kaniko, or `nix2container`. SF does
not depend on the builder implementation. The deployment contract starts at the
OCI image plus release manifest.
## Server Runtime
The server image starts:
```bash
node /opt/sf/dist/loader.js server /workspace --host 0.0.0.0 --port 4000
```
The web host receives `SF_RELEASE_MANIFEST`, `SF_WEB_PROJECT_CWD`,
`SF_WEB_HOST`, and `SF_WEB_PORT` in its environment. Probes are unauthenticated
so Kubernetes, Traefik, and Forgejo can verify rollouts without a browser token.
On vega, the local production server may run from the live checkout while still
being containerised:
```bash
npm run docker:vega:up
```
That profile mounts this SF checkout at `/opt/sf`, mounts the controlled repo at
`/workspace`, persists `~/.sf`, and binds port 4000 to
`${SF_VEGA_BIND:-127.0.0.1}`. `SF_WORKSPACE_DIR` selects which repo the server
controls; it defaults to this checkout for dogfooding, but the same server code
can be reused with any repo:
```bash
SF_WORKSPACE_DIR=/home/mhugo/code/other-repo npm run docker:vega:up
```
Set `SF_VEGA_BIND` to the vega Tailscale address when the server should be
reachable over Tailscale; do not bind public `0.0.0.0` unless a proxy/firewall
owns access control.
On hosts without the Docker Compose plugin, `npm run docker:vega:up` uses
`scripts/run-vega-source-server.mjs` to build `docker/Dockerfile.source-server`
and run the equivalent `docker run` command directly. This is one SF server
implementation and image, with one running server process per workspace repo.
Each workspace gets a stable container name derived from the workspace path. A
restart replaces only that workspace's container; other repo servers keep
running. If `SF_VEGA_PORT` is not set, the runner picks the first free port in
`4000-4099`, so several repos can run at the same time.
## Promotion
Test must roll before prod:
1. set test deployment image to the new digest
2. wait for rollout
3. call `/api/healthz`
4. call `/api/ready`
5. call `/api/version`
6. promote the same image digest to prod
7. repeat the same probes
Prod must not install `latest` from npm during rollout. Runtime auto-update
means the deployment controller rolls a verified image; it does not mean the
running process mutates its own package tree.
## Reload Model
For a source-mounted vega container, the foreground process is the staged Next
standalone server at `dist/web/standalone/server.js`. Rebuild or restart the
container after changing server/web code. In Kubernetes or k3s, rollout
replacement is the reload mechanism. Long term, CLI commands should call the
server RPC surface by default when a healthy server owns the project, while
local `sf server` remains the bootstrap and recovery path.
## Open Work
- Wire `/api/version` into the web footer/admin panel.
- Add an RPC smoke probe once the stable server RPC endpoint is finalized.
- Move the Forgejo workflow's deployment target names into `/srv/infra` GitOps
values when the cluster manifests exist.

View file

@ -53,6 +53,11 @@
"build": "npm run build:core && node scripts/build-web-if-stale.cjs",
"stage:web-host": "node scripts/stage-web-standalone.cjs",
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
"release:manifest": "node scripts/generate-release-manifest.mjs",
"docker:build-sf-server": "docker build -f docker/Dockerfile.sf-server -t ghcr.io/singularity-ng/sf-server .",
"docker:vega:up": "node scripts/run-vega-source-server.mjs up",
"docker:vega:logs": "node scripts/run-vega-source-server.mjs logs",
"docker:vega:down": "node scripts/run-vega-source-server.mjs down",
"docs:features": "node scripts/generate-features-inventory.mjs",
"copy-resources": "node scripts/copy-resources.cjs",
"copy-themes": "node scripts/copy-themes.cjs",

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* generate-release-manifest.mjs emit the immutable build contract consumed by
* SF server health checks and deployment automation.
*
* Purpose: give Forgejo, Kubernetes, and the running web surface one small JSON
* artifact that proves which source revision, image, and verification gates are
* being promoted.
*
* Consumer: Forgejo self-deploy workflow, Docker image build, and web
* /api/version readiness surfaces.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const args = process.argv.slice(2);
const outIndex = args.indexOf("--out");
const outPath =
outIndex >= 0 && args[outIndex + 1]
? resolve(process.cwd(), args[outIndex + 1])
: resolve(root, "dist", "sf-release-manifest.json");
const packageJson = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"),
);
const manifest = {
schemaVersion: 1,
name: packageJson.name,
version: packageJson.version,
git: {
sha:
env("SF_GIT_SHA") ??
env("GITHUB_SHA") ??
env("FORGEJO_SHA") ??
git(["rev-parse", "HEAD"]),
ref:
env("SF_GIT_REF") ??
env("GITHUB_REF_NAME") ??
env("FORGEJO_REF_NAME") ??
env("CI_COMMIT_REF_NAME") ??
git(["rev-parse", "--abbrev-ref", "HEAD"]),
},
image: {
repository:
env("SF_IMAGE_REPOSITORY") ??
env("SF_RELEASE_IMAGE")?.replace(/@sha256:.+$/, "") ??
null,
ref: env("SF_RELEASE_IMAGE") ?? null,
digest: env("SF_RELEASE_IMAGE_DIGEST") ?? null,
registry: env("SF_RELEASE_REGISTRY") ?? null,
},
build: {
source: env("CI_SERVER_NAME") ?? env("GITHUB_SERVER_URL") ?? "local",
workflow: env("GITHUB_WORKFLOW") ?? env("CI_WORKFLOW") ?? null,
runId: env("GITHUB_RUN_ID") ?? env("CI_PIPELINE_ID") ?? null,
runNumber: env("GITHUB_RUN_NUMBER") ?? env("CI_PIPELINE_NUMBER") ?? null,
builtAt: new Date().toISOString(),
node: process.version,
npm: command(["npm", "--version"]),
},
server: {
defaultHost: "0.0.0.0",
defaultPort: 4000,
probes: {
healthz: "/api/healthz",
ready: "/api/ready",
version: "/api/version",
},
},
gates: [
"npm ci",
"npm --prefix web ci",
"npm run build:core",
"npm run build:web-host",
"npm run typecheck:extensions",
"npm run test:unit",
"server:/api/healthz",
"server:/api/ready",
"server:/api/version",
],
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(`${outPath}.tmp`, `${JSON.stringify(manifest, null, 2)}\n`);
renameSync(`${outPath}.tmp`, outPath);
process.stdout.write(`${outPath}\n`);
function env(name) {
const value = process.env[name]?.trim();
return value ? value : null;
}
function git(args) {
return command(["git", ...args]);
}
function command(parts) {
try {
return execFileSync(parts[0], parts.slice(1), {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
}

View file

@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* run-vega-source-server.mjs start the source-mounted SF server container on
* vega without requiring Docker Compose.
*
* Purpose: keep the local production server containerized while using the live
* checkout at /home/mhugo/code/singularity-forge as the source of truth.
*
* Consumer: `npm run docker:vega:up` on vega.
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createServer } from "node:net";
import { homedir } from "node:os";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const image = process.env.SF_VEGA_IMAGE || "sf-source-server:vega";
const bind = process.env.SF_VEGA_BIND || "127.0.0.1";
const workspace = resolve(process.env.SF_WORKSPACE_DIR || root);
const name = process.env.SF_VEGA_CONTAINER || defaultContainerName(workspace);
const uid = process.env.PUID || String(process.getuid?.() ?? 1000);
const gid = process.env.PGID || String(process.getgid?.() ?? 1000);
const command = process.argv[2] ?? "up";
if (command === "--help" || command === "-h" || command === "help") {
process.stdout.write(`Usage:
npm run docker:vega:up
SF_WORKSPACE_DIR=/path/to/repo npm run docker:vega:up
node scripts/run-vega-source-server.mjs print
node scripts/run-vega-source-server.mjs logs
node scripts/run-vega-source-server.mjs down
Environment:
SF_WORKSPACE_DIR repo mounted at /workspace, defaults to this checkout
SF_VEGA_BIND host bind address, defaults to 127.0.0.1
SF_VEGA_PORT fixed host port; unset auto-picks 4000-4099
SF_VEGA_CONTAINER explicit container name
`);
process.exit(0);
}
if (command === "print") {
const port = await resolvePort();
process.stdout.write(
JSON.stringify(
{ name, image, bind, port, workspace, sfSource: root },
null,
2,
) + "\n",
);
process.exit(0);
}
if (command === "logs") {
run("docker", ["logs", "-f", "--tail=200", name]);
process.exit(0);
}
if (command === "down") {
run("docker", ["rm", "-f", name]);
process.exit(0);
}
if (command !== "up") {
process.stderr.write(`Unknown command: ${command}\n`);
process.exit(2);
}
const port = await resolvePort();
const allowedOrigins =
process.env.SF_WEB_ALLOWED_ORIGINS ||
`http://127.0.0.1:${port},http://localhost:${port}`;
run("docker", [
"build",
"-f",
"docker/Dockerfile.source-server",
"-t",
image,
".",
]);
spawnSync("docker", ["rm", "-f", name], { stdio: "ignore" });
run("docker", [
"run",
"-d",
"--name",
name,
"--restart",
"unless-stopped",
"--user",
`${uid}:${gid}`,
"-p",
`${bind}:${port}:4000`,
"-e",
"HOME=/home/node",
"-e",
"NODE_ENV=development",
"-e",
"SF_SOURCE_ROOT=/opt/sf",
"-e",
"SF_RUNTIME_SOURCE_ROOT=/opt/sf",
"-e",
"SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json",
"-e",
"SF_WEB_PROJECT_CWD=/workspace",
"-e",
"HOSTNAME=0.0.0.0",
"-e",
"PORT=4000",
"-e",
"SF_WEB_HOST=0.0.0.0",
"-e",
"SF_WEB_PORT=4000",
"-e",
`SF_WEB_ALLOWED_ORIGINS=${allowedOrigins}`,
"-e",
"SF_DEV_SERVER_WATCH=1",
"-v",
`${root}:/opt/sf`,
"-v",
`${workspace}:/workspace`,
"-v",
`${homedir()}/.sf:/home/node/.sf`,
"-v",
`${homedir()}/.gitconfig:/home/node/.gitconfig:ro`,
image,
"node",
"/opt/sf/dist/web/standalone/server.js",
]);
process.stdout.write(`${name} listening on ${bind}:${port}\n`);
process.stdout.write(`SF source: ${root}\n`);
process.stdout.write(`Workspace: ${workspace}\n`);
async function resolvePort() {
if (process.env.SF_VEGA_PORT) return process.env.SF_VEGA_PORT;
for (let candidate = 4000; candidate <= 4099; candidate++) {
if (await isPortAvailable(candidate)) return String(candidate);
}
process.stderr.write("No free SF vega port found in 4000-4099\n");
process.exit(1);
}
function isPortAvailable(port) {
return new Promise((resolveAvailable) => {
const server = createServer();
server.once("error", () => resolveAvailable(false));
server.once("listening", () => {
server.close(() => resolveAvailable(true));
});
server.listen(port, bind);
});
}
function defaultContainerName(path) {
const slug = basename(path)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const hash = createHash("sha1").update(path).digest("hex").slice(0, 8);
return `sf-server-${slug || "workspace"}-${hash}`;
}
function run(command, args) {
const result = spawnSync(command, args, {
cwd: root,
stdio: "inherit",
env: process.env,
});
if (result.status !== 0) process.exit(result.status ?? 1);
}

View file

@ -0,0 +1,37 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
describe("release manifest", () => {
test("generate_release_manifest_when_run_from_repo_records_server_probe_contract", () => {
const dir = mkdtempSync(join(tmpdir(), "sf-release-manifest-"));
const out = join(dir, "manifest.json");
execFileSync(
process.execPath,
["scripts/generate-release-manifest.mjs", "--out", out],
{
cwd: process.cwd(),
env: {
...process.env,
SF_RELEASE_IMAGE: "registry.example/sf-server:test",
SF_RELEASE_IMAGE_DIGEST: "sha256:abc",
},
},
);
const manifest = JSON.parse(readFileSync(out, "utf8"));
expect(manifest.schemaVersion).toBe(1);
expect(manifest.name).toBe("singularity-forge");
expect(manifest.server.defaultPort).toBe(4000);
expect(manifest.server.probes).toEqual({
healthz: "/api/healthz",
ready: "/api/ready",
version: "/api/version",
});
expect(manifest.image.ref).toBe("registry.example/sf-server:test");
expect(manifest.image.digest).toBe("sha256:abc");
expect(manifest.gates).toContain("npm run build:web-host");
});
});

110
src/web/release-info.ts Normal file
View file

@ -0,0 +1,110 @@
/**
* release-info.ts read SF server release identity from env and manifest files.
*
* Purpose: let health probes and deployment controllers verify the exact server
* artifact they are talking to without depending on the interactive CLI.
*
* Consumer: web /api/healthz, /api/ready, and /api/version routes.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export type ReleaseInfo = {
ok: boolean;
service: "sf-server";
version: string;
gitSha: string | null;
gitRef: string | null;
image: {
ref: string | null;
digest: string | null;
repository: string | null;
};
manifestPath: string | null;
manifestLoaded: boolean;
projectCwd: string | null;
host: string | null;
port: number | null;
uptimeSeconds: number;
timestamp: string;
};
type Manifest = {
version?: unknown;
git?: { sha?: unknown; ref?: unknown };
image?: {
ref?: unknown;
digest?: unknown;
repository?: unknown;
};
};
/**
* Return the release identity advertised by the current SF server process.
*
* Purpose: give probes a stable, JSON-serialisable shape whether the server was
* launched from source, npm, or a Forgejo-built image.
*
* Consumer: Next.js API routes under web/app/api.
*/
export function getReleaseInfo(
env: NodeJS.ProcessEnv = process.env,
now = new Date(),
): ReleaseInfo {
const manifestPath = resolveManifestPath(env);
const manifest = readManifest(manifestPath);
const version =
stringValue(env.SF_VERSION) ?? stringValue(manifest?.version) ?? "0.0.0";
const port = Number.parseInt(env.SF_WEB_PORT ?? env.PORT ?? "", 10);
return {
ok: true,
service: "sf-server",
version,
gitSha:
stringValue(env.SF_GIT_SHA) ??
stringValue(env.GITHUB_SHA) ??
stringValue(manifest?.git?.sha),
gitRef:
stringValue(env.SF_GIT_REF) ??
stringValue(env.GITHUB_REF_NAME) ??
stringValue(manifest?.git?.ref),
image: {
ref:
stringValue(env.SF_RELEASE_IMAGE) ?? stringValue(manifest?.image?.ref),
digest:
stringValue(env.SF_RELEASE_IMAGE_DIGEST) ??
stringValue(manifest?.image?.digest),
repository:
stringValue(env.SF_IMAGE_REPOSITORY) ??
stringValue(manifest?.image?.repository),
},
manifestPath,
manifestLoaded: manifest !== null,
projectCwd: stringValue(env.SF_WEB_PROJECT_CWD),
host: stringValue(env.SF_WEB_HOST ?? env.HOSTNAME),
port: Number.isFinite(port) ? port : null,
uptimeSeconds: Math.round(process.uptime()),
timestamp: now.toISOString(),
};
}
function resolveManifestPath(env: NodeJS.ProcessEnv): string | null {
const explicit = stringValue(env.SF_RELEASE_MANIFEST);
if (explicit) return explicit;
const packageRoot = stringValue(env.SF_WEB_PACKAGE_ROOT) ?? process.cwd();
const candidate = join(packageRoot, "dist", "sf-release-manifest.json");
return existsSync(candidate) ? candidate : null;
}
function readManifest(path: string | null): Manifest | null {
if (!path) return null;
try {
return JSON.parse(readFileSync(path, "utf8")) as Manifest;
} catch {
return null;
}
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}

View file

@ -0,0 +1,10 @@
import { getReleaseInfo } from "../../../../src/web/release-info.ts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(): Promise<Response> {
return Response.json(getReleaseInfo(), {
headers: { "Cache-Control": "no-store" },
});
}

View file

@ -0,0 +1,27 @@
import { existsSync } from "node:fs";
import { getReleaseInfo } from "../../../../src/web/release-info.ts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(): Promise<Response> {
const release = getReleaseInfo();
const projectReady =
release.projectCwd === null || existsSync(release.projectCwd);
const ready = release.ok && projectReady;
return Response.json(
{
...release,
ready,
checks: {
projectCwd: projectReady ? "pass" : "fail",
manifest: release.manifestLoaded ? "pass" : "absent",
},
},
{
status: ready ? 200 : 503,
headers: { "Cache-Control": "no-store" },
},
);
}

View file

@ -0,0 +1,10 @@
import { getReleaseInfo } from "../../../../src/web/release-info.ts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(): Promise<Response> {
return Response.json(getReleaseInfo(), {
headers: { "Cache-Control": "no-store" },
});
}