Run a persistent remote development preview
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 17s
python CI and deployment / static-checks (push) Successful in 4s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 4s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 17s
python CI and deployment / static-checks (push) Successful in 4s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 4s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
This commit is contained in:
@@ -32,12 +32,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 2
|
||||
- name: Check patch, syntax and source contracts without building
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
for script in scripts/*.sh; do bash -n "$script"; done
|
||||
for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/preview/fingerprint.mjs
|
||||
node --check scripts/verify-studio-visual.cjs
|
||||
node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
|
||||
node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Remote development preview source sync
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'scripts/preview/**'
|
||||
- '.gitea/workflows/preview.yml'
|
||||
concurrency:
|
||||
group: development-preview
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
sync-source:
|
||||
# One preview slot follows one branch. Setup changes use an explicit refresh.
|
||||
if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web')
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Initialize once, otherwise synchronize files only
|
||||
env:
|
||||
SOURCE_SHA: ${{ gitea.sha }}
|
||||
SOURCE_BRANCH: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
if [ ! -s /opt/prompt-preview/fingerprint ]; then
|
||||
bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12
|
||||
fi
|
||||
bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH"
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 2
|
||||
- name: Check Python and deployment syntax without executing tests
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
const scrypt = promisify(derive);
|
||||
export const ACCOUNT_COOKIE = 'wiki_account';
|
||||
export const ACCOUNT_COOKIE = process.env.ACCOUNT_COOKIE_NAME || 'wiki_account';
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ACCOUNT_COOKIE)) throw Error('Invalid ACCOUNT_COOKIE_NAME');
|
||||
export const SESSION_TTL = 7 * 86400;
|
||||
export function normalizeEmail(value: unknown) {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const SESSION_COOKIE = 'prompt_session';
|
||||
export const SESSION_COOKIE = process.env.AUTH_COOKIE_NAME || 'prompt_session';
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(SESSION_COOKIE)) throw Error('Invalid AUTH_COOKIE_NAME');
|
||||
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
|
||||
export type AuthConfig = { secret: string; jwtKey: string };
|
||||
export function authConfig(): AuthConfig | null {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (process.env.PREVIEW_MODE !== 'true') return res.status(404).end();
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).end();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
try {
|
||||
const status = JSON.parse(await readFile('/preview-status/status.json', 'utf8'));
|
||||
return req.method === 'HEAD' ? res.status(200).end() : res.status(200).json(status);
|
||||
} catch {
|
||||
return res.status(503).json({ error: 'Preview source has not synchronized yet' });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { authConfig, SESSION_COOKIE, validSession } from './lib/auth';
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
if (process.env.PREVIEW_MODE === 'true' && path === '/api/dev-preview' && ['GET','HEAD'].includes(request.method)) return NextResponse.next();
|
||||
const publicBrandAssets = ['/favicon.ico','/apple-touch-icon.png','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/studio/favicon.ico','/studio/apple-touch-icon.png','/studio/brand/logo-light.svg','/studio/brand/logo-dark.svg','/studio/brand/icon-dark.svg'];
|
||||
if (['GET','HEAD'].includes(request.method) && publicBrandAssets.includes(path)) return NextResponse.next({headers:{'Referrer-Policy':'same-origin'}});
|
||||
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
x-preview-env: &preview-env
|
||||
DATABASE_URL: postgresql://prompt:${POSTGRES_PASSWORD:?required}@postgres:5432/prompt
|
||||
S3_ENDPOINT: http://objects:9000
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?required}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:?required}
|
||||
S3_BUCKET: prompt-preview
|
||||
AUTH_CONFIG_FILE: /auth/config.json
|
||||
AUTH_COOKIE_SECURE: "false"
|
||||
AUTH_COOKIE_NAME: preview_prompt_session
|
||||
ACCOUNT_COOKIE_NAME: preview_wiki_account
|
||||
ACCOUNT_PUBLIC_ORIGIN: http://8.211.154.134:${PREVIEW_PORT:-18136}
|
||||
STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000
|
||||
SMTP_HOST: 127.0.0.1
|
||||
SMTP_PORT: "1"
|
||||
SMTP_USERNAME: preview
|
||||
SMTP_PASSWORD: ""
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
PREVIEW_MODE: "true"
|
||||
COREPACK_HOME: /tools/corepack
|
||||
COREPACK_ENABLE_NETWORK: "0"
|
||||
npm_config_offline: "true"
|
||||
services:
|
||||
waterfall-wiki:
|
||||
image: ${PREVIEW_TOOL_IMAGE:?use a verified test image}
|
||||
working_dir: /repo/apps/waterfall-wiki
|
||||
user: "1000:1000"
|
||||
command: ["pnpm", "dev", "--hostname", "0.0.0.0", "--port", "3000"]
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *preview-env
|
||||
NODE_OPTIONS: --max-old-space-size=1536
|
||||
mem_limit: 2g
|
||||
cpus: 1.0
|
||||
ports: ["0.0.0.0:${PREVIEW_PORT:-18136}:3000"]
|
||||
volumes:
|
||||
- /opt/prompt-preview/source:/repo
|
||||
- dependencies:/repo/apps/waterfall-wiki/node_modules
|
||||
- type: volume
|
||||
source: next-cache
|
||||
target: /repo/apps/waterfall-wiki/.next
|
||||
- tools:/tools:ro
|
||||
- /opt/prompt-preview/status:/preview-status:ro
|
||||
- auth:/auth:ro
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: prompt
|
||||
POSTGRES_USER: prompt
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
volumes: ["database:/var/lib/postgresql/data"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U prompt -d prompt"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
objects:
|
||||
image: rustfs/rustfs:1.0.3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:?required}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:?required}
|
||||
RUSTFS_VOLUMES: /data
|
||||
RUSTFS_ADDRESS: :9000
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
volumes: ["objects:/data"]
|
||||
prepare-tools:
|
||||
image: ${PREVIEW_TOOL_IMAGE:?required}
|
||||
profiles: [setup]
|
||||
user: root
|
||||
volumes: ["tools:/tools"]
|
||||
command: ["/bin/sh", "-ec", "mkdir -p /tools/corepack && cp -a /root/.cache/node/corepack/. /tools/corepack/ && chown -R 1000:1000 /tools"]
|
||||
migrate:
|
||||
image: ${PREVIEW_TOOL_IMAGE:?required}
|
||||
profiles: [setup]
|
||||
environment: *preview-env
|
||||
command: ["node", "dist/bootstrap.cjs"]
|
||||
auth-admin:
|
||||
image: ${PREVIEW_TOOL_IMAGE:?required}
|
||||
profiles: [setup]
|
||||
user: root
|
||||
environment: *preview-env
|
||||
volumes: ["auth:/auth"]
|
||||
command: ["node", "dist/auth-cli.cjs", "init"]
|
||||
volumes:
|
||||
tools:
|
||||
dependencies:
|
||||
next-cache:
|
||||
auth:
|
||||
database:
|
||||
objects:
|
||||
@@ -0,0 +1,13 @@
|
||||
// Startup/dependency/schema changes require an explicit preview refresh.
|
||||
import {readFile,readdir} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
const root=resolve(process.argv[2] || '.');
|
||||
const hash=createHash('sha256');
|
||||
for (const name of ['apps/waterfall-wiki/pnpm-lock.yaml','pnpm-workspace.yaml','apps/waterfall-wiki/next.config.ts','apps/waterfall-wiki/tsconfig.json','apps/waterfall-wiki/postcss.config.mjs']) hash.update(name).update(await readFile(resolve(root,name)));
|
||||
for (const name of ['apps/waterfall-wiki/package.json','packages/brand/package.json']) {
|
||||
const p=JSON.parse(await readFile(resolve(root,name),'utf8'));
|
||||
hash.update(name).update(JSON.stringify({dependencies:p.dependencies,devDependencies:p.devDependencies,optionalDependencies:p.optionalDependencies,peerDependencies:p.peerDependencies,engines:p.engines,packageManager:p.packageManager,dev:p.scripts?.dev,prepare:p.scripts?.['brand:prepare'],exports:p.exports}));
|
||||
}
|
||||
for (const name of (await readdir(resolve(root,'apps/waterfall-wiki/db'))).filter(n=>n.endsWith('.sql')).sort()) hash.update(name).update(await readFile(resolve(root,'apps/waterfall-wiki/db',name)));
|
||||
console.log(hash.digest('hex'));
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
SOURCE=$(realpath "${1:?checkout directory required}")
|
||||
IMAGE=${2:?verified integration image required}
|
||||
PORT=${PREVIEW_PORT:-18136}
|
||||
[[ "$IMAGE" =~ ^waterfall-wiki-tests:[0-9a-f]{40}$ ]] || exit 2
|
||||
[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 18136 && PORT <= 65535 )) || exit 2
|
||||
command -v rsync >/dev/null
|
||||
command -v ss >/dev/null
|
||||
docker image inspect "$IMAGE" >/dev/null
|
||||
ROOT=/opt/prompt-preview
|
||||
exec 7>/var/lock/prompt-preview-init.lock
|
||||
flock -w 30 7
|
||||
if [ -s "$ROOT/fingerprint" ]; then echo 'Already initialized; use sync.sh, or perform an explicit manual refresh' >&2; exit 1; fi
|
||||
if ss -H -ltn "sport = :$PORT" | grep -q .; then echo "Preview port $PORT is occupied; choose another PREVIEW_PORT" >&2; exit 1; fi
|
||||
# The prebuilt tooling image must match dependency, startup and schema inputs.
|
||||
expected=$(node "$SOURCE/scripts/preview/fingerprint.mjs" "$SOURCE")
|
||||
actual=$(docker run --rm --entrypoint node \
|
||||
--mount "type=bind,src=$SOURCE/scripts/preview/fingerprint.mjs,dst=/tmp/preview-fingerprint.mjs,readonly" \
|
||||
"$IMAGE" /tmp/preview-fingerprint.mjs /repo)
|
||||
[[ "$actual" == "$expected" ]] || { echo 'Tooling image does not match preview dependencies/configuration/schema' >&2; exit 1; }
|
||||
install -d -m 0700 "$ROOT"
|
||||
install -d -m 0755 -o 1000 -g 1000 "$ROOT/source" "$ROOT/status"
|
||||
# Create independent preview credentials only once, never copy production secrets.
|
||||
if [ ! -s "$ROOT/env" ]; then
|
||||
ROOT="$ROOT" IMAGE="$IMAGE" PORT="$PORT" python3 - <<'PY'
|
||||
import os,secrets,pathlib
|
||||
p=pathlib.Path(os.environ['ROOT'])/'env'
|
||||
with p.open('x') as f:
|
||||
os.chmod(p,0o600)
|
||||
f.write('PREVIEW_TOOL_IMAGE='+os.environ['IMAGE']+'\nPREVIEW_PORT='+os.environ['PORT']+'\n')
|
||||
for key in ['POSTGRES_PASSWORD','S3_ACCESS_KEY','S3_SECRET_KEY']:f.write(key+'='+secrets.token_hex(24)+'\n')
|
||||
PY
|
||||
fi
|
||||
rsync -a --delete --chown=1000:1000 \
|
||||
--exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='dist' \
|
||||
--exclude='*.tsbuildinfo' --exclude='.env' --exclude='.env.*' --exclude='.gitea' \
|
||||
"$SOURCE/" "$ROOT/source/"
|
||||
install -m 0644 "$SOURCE/scripts/preview/compose.yml" "$ROOT/compose.yml"
|
||||
compose(){ docker compose -p prompt-preview --env-file "$ROOT/env" -f "$ROOT/compose.yml" "$@"; }
|
||||
compose up -d --no-build --no-recreate --wait --wait-timeout 120 postgres objects
|
||||
compose run --rm --no-deps prepare-tools
|
||||
compose run --rm --no-deps auth-admin
|
||||
compose run --rm --no-deps migrate
|
||||
# The dependency volume is seeded from the already-verified image, no npm install.
|
||||
compose up -d --no-build --no-recreate --no-deps waterfall-wiki
|
||||
printf '%s\n' "$expected" > "$ROOT/fingerprint"
|
||||
for attempt in {1..90}; do
|
||||
if curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health" >/dev/null; then
|
||||
echo "Preview initialized on port $PORT; pnpm dev remains running"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
compose logs --tail 60 waterfall-wiki
|
||||
exit 1
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
SOURCE=$(realpath "${1:?checkout directory required}")
|
||||
SHA=${2:?commit SHA required}
|
||||
BRANCH=${3:?branch required}
|
||||
[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2
|
||||
ROOT=/opt/prompt-preview
|
||||
exec 8>/var/lock/prompt-preview-sync.lock
|
||||
flock -w 30 8
|
||||
candidate=$(node "$SOURCE/scripts/preview/fingerprint.mjs" "$SOURCE")
|
||||
if [ ! -s "$ROOT/fingerprint" ]; then
|
||||
echo 'Preview is not initialized; run scripts/preview/init.sh once with a verified tooling image' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat "$ROOT/fingerprint")" != "$candidate" ]; then
|
||||
echo 'Preview refresh required: dependencies, startup configuration or database schema changed. No install/restart/migration was performed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
id=$(docker compose -p prompt-preview --env-file "$ROOT/env" -f "$ROOT/compose.yml" ps -q waterfall-wiki)
|
||||
test -n "$id" && test "$(docker inspect --format '{{.State.Running}}' "$id")" = true
|
||||
# No git credentials, dependency folders, compiler output or environment files are copied.
|
||||
rsync -a --checksum --no-times --delete --chown=1000:1000 \
|
||||
--exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='dist' \
|
||||
--exclude='*.tsbuildinfo' --exclude='.env' --exclude='.env.*' --exclude='.gitea' \
|
||||
--exclude='/apps/waterfall-wiki/public/brand/' --exclude='/apps/waterfall-wiki/public/favicon.ico' --exclude='/apps/waterfall-wiki/public/apple-touch-icon.png' "$SOURCE/" "$ROOT/source/"
|
||||
# Keep generated public marks current using file copies only.
|
||||
assets="$ROOT/source/packages/brand/assets"
|
||||
public="$ROOT/source/apps/waterfall-wiki/public"
|
||||
mkdir -p "$public/brand"
|
||||
copy_asset() {
|
||||
cmp -s "$1" "$2" || install -o 1000 -g 1000 -m 0644 "$1" "$2"
|
||||
}
|
||||
for name in logo-light.svg logo-dark.svg icon-dark.svg; do copy_asset "$assets/svg/$name" "$public/brand/$name"; done
|
||||
copy_asset "$assets/icons/favicon.ico" "$public/favicon.ico"
|
||||
copy_asset "$assets/icons/apple-touch-icon.png" "$public/apple-touch-icon.png"
|
||||
SHA="$SHA" BRANCH="$BRANCH" DEST="$ROOT/status/status.json" python3 - <<'META'
|
||||
import os,json,pathlib,datetime
|
||||
destination=pathlib.Path(os.environ['DEST'])
|
||||
temporary=destination.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps({'sha':os.environ['SHA'],'branch':os.environ['BRANCH'],'syncedAt':datetime.datetime.now(datetime.timezone.utc).isoformat()})+'\n')
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(destination)
|
||||
META
|
||||
echo "Preview synchronized: $BRANCH $SHA (container $id)"
|
||||
Reference in New Issue
Block a user