Add cached Web-only releases through existing Docker runtime
PROMPT Docker CI and deployment / verify-and-deploy (push) Successful in 5m36s

This commit is contained in:
2026-10-23 19:45:26 +08:00
parent 83e19d31ca
commit b67574f950
5 changed files with 192 additions and 1 deletions
+27 -1
View File
@@ -27,20 +27,46 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify release classification policy
run: bash tests/release-mode.sh
- name: Choose full or Web-only release
id: release
run: |
set -eu
mode=full
if [ "${{ gitea.event_name }}" = push ] && [ "${{ gitea.ref }}" = refs/heads/main ]; then
mode=$(bash scripts/release-mode.sh)
if [ "$mode" = web ]; then
image=$(sed -n 's/^PROMPT_IMAGE=//p' /opt/prompt/current/release.env)
if ! docker image inspect "prompt-tests:${image#prompt:}" >/dev/null 2>&1; then mode=full; fi
fi
fi
echo "mode=$mode" >> "$GITHUB_OUTPUT"
echo "Release mode: $mode"
- name: Build and deploy Web using the existing Docker environment
if: steps.release.outputs.mode == 'web'
run: bash scripts/deploy-web.sh "$RELEASE_SHA"
- name: Check preinstalled Docker Engine and Compose
if: steps.release.outputs.mode != 'web'
run: |
set -eu
command -v docker
docker compose version
docker info >/dev/null
- name: Validate the Compose configuration
if: steps.release.outputs.mode != 'web'
run: docker compose --env-file .env.example config --quiet
- name: Type check, unit tests, production build and smoke test inside Docker
if: steps.release.outputs.mode != 'web'
run: docker build --target integration -t "$PROMPT_TEST_IMAGE" .
- name: Build non-root production image with bundled FFmpeg
if: steps.release.outputs.mode != 'web'
run: docker build --target runtime -t "$PROMPT_IMAGE" .
- name: Test the complete isolated Docker stack
if: steps.release.outputs.mode != 'web'
run: bash scripts/integration.sh
- name: Deploy the persistent Docker Compose stack
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
if: steps.release.outputs.mode != 'web' && gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
run: bash scripts/deploy.sh "$RELEASE_SHA"
+10
View File
@@ -7,3 +7,13 @@
Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。push 和 PR 对纯文档改动使用 paths,但 public 下的 Markdown 属于产品资源,必须构建部署。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。仍保留类型检查、单元测试、smoke 与完整隔离栈集成测试。
当前页面为 Next.js SSR 与编译组件,改 TSX 仍需 next build。把 HTML 的 COPY 移到最后不能避免编译。独立挂载静态文件会破坏镜像版本与回滚的一致性;只有真正独立、无需编译的内容才适合后续拆成单独发布渠道。
## Web 快速发布
main 的 push 与当前已部署 SHA 比较;只有页面、组件、样式、技能文档数据和 public 改动才走 Web 路径,API、共享库、依赖、配置、worker、脚本及数据库变化均回退完整流水线。PR 和手动运行始终完整验证;依赖构建镜像缺失也回退完整流水线。
Web 路径复用当前 runtime 对应的已验证 integration 镜像作为工具环境,在临时容器构建生产 Web,不运行 docker build。编译缓存按基础镜像隔离持久保存。类型检查、单元测试和 Web smoke 仍同步执行,完整隔离栈测试只在全量路径执行。
完整 standalone 产物存入 `/opt/prompt/web-artifacts/<sha>.<run>`,只读挂载到 Web 容器;仅重建 Web,保留 runtime 镜像、worker、Postgres 和 RustFS。切换前检查 main 与当前发布,部署锁与全量流程共用;失败自动恢复旧 Web Compose 配置。发布文件保留变量表达式,不写入生产秘密。健康检查通过后切换 current;原完整发布也能回滚到 Web 快速发布。
首次接入流水线变更仍走完整验证;后续 Web 更新速度需要实测,不能承诺 HMR 的毫秒级反馈。每次发布完整 Web 产物,不把个别 chunk 混入正在运行的 Next.js 版本。
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi
SHA=${1:?Release SHA is required}
[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1
ROOT=/opt/prompt
PREVIOUS=$(readlink -f "$ROOT/current")
test -f "$PREVIOUS/compose.yml"
BASE=$(sed -n 's/^RELEASE_SHA=//p' "$PREVIOUS/release.env")
test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$SHA"
# Never let a manually invoked fast release bypass the change policy.
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$PWD"
test "$(bash scripts/release-mode.sh "$BASE")" = web
IMAGE=$(sed -n 's/^PROMPT_IMAGE=//p' "$PREVIOUS/release.env")
[[ "$IMAGE" =~ ^prompt:[0-9a-f]{40}$ ]] || exit 1
BUILDER="prompt-tests:${IMAGE#prompt:}"
docker image inspect "$BUILDER" >/dev/null
RELEASE="$ROOT/releases/$SHA"
CACHE="$ROOT/web-cache/${IMAGE#prompt:}"
mkdir -p "$ROOT/web-artifacts" "$ROOT/web-cache" "$ROOT/releases"
exec 8>"$ROOT/.web-build.lock"
flock -w 300 8
# Build away from the running application. Only the compiler cache is shared.
ARTIFACT=$(mktemp -d "$ROOT/web-artifacts/$SHA.XXXXXX")
mkdir -p "$CACHE"
CONTAINER="prompt-web-build-$SHA"
cleanup(){ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
trap cleanup EXIT
# The existing validated test image contains the matching Node/toolchain/dependencies.
# Replacing source in a disposable container does not commit/export image layers.
docker run --name "$CONTAINER" --user 0:0 \
--mount "type=bind,src=$PWD,dst=/source,readonly" \
--mount "type=bind,src=$ARTIFACT,dst=/output" \
--mount "type=bind,src=$CACHE,dst=/app/.next/cache" \
--entrypoint bash "$BUILDER" -ceu '
cd /app
rm -rf src public scripts tests db seed
cp -a /source/src /source/public /source/scripts /source/tests /source/db /source/seed ./
cp /source/package.json /source/package-lock.json /source/tsconfig.json /source/next-env.d.ts /source/next.config.ts /source/postcss.config.mjs ./
npm run typecheck
npm test
node node_modules/next/dist/bin/next build
node scripts/prepare-standalone.mjs
npm run test:smoke
cp -a .next/standalone/. /output/
chmod -R a+rX /output
'
cleanup
# Serialize cutover with full deployments and recheck the running release.
exec 9>"$ROOT/.deploy.lock"
flock -w 300 9
test "$(readlink -f "$ROOT/current")" = "$PREVIOUS"
LATEST=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$LATEST" != "$SHA" ]; then echo 'Skipping stale Web release'; exit 0; fi
mkdir -p "$RELEASE"
cp "$PREVIOUS/release.env" "$RELEASE/release.env"
sed -i "s/^RELEASE_SHA=.*/RELEASE_SHA=$SHA/" "$RELEASE/release.env"
cat > "$RELEASE/web.override.yml" <<YAML
services:
web:
command: ["node", "/web/server.js"]
volumes:
- type: bind
source: $ARTIFACT
target: /web
read_only: true
YAML
# Preserve variable expressions: never materialize production secrets into release files.
docker compose -p prompt -f "$PREVIOUS/compose.yml" -f "$RELEASE/web.override.yml" config --no-interpolate > "$RELEASE/compose.yml"
compose_at(){ local dir="$1"; shift; env -u PROMPT_IMAGE -u PROMPT_TEST_IMAGE -u RELEASE_SHA -u WEB_PORT -u WEB_BIND_ADDRESS docker compose -p prompt --env-file /etc/prompt.env --env-file "$dir/release.env" -f "$dir/compose.yml" "$@"; }
CUTOVER=0
rollback(){
code=$?
trap - ERR
if [ "$CUTOVER" -eq 1 ]; then compose_at "$PREVIOUS" up -d --no-deps --no-build --wait --wait-timeout 120 web || true; fi
exit "$code"
}
trap rollback ERR
CUTOVER=1
compose_at "$RELEASE" up -d --no-deps --no-build --wait --wait-timeout 120 web
compose_at "$RELEASE" exec -T web node /app/container-health.mjs
compose_at "$RELEASE" exec -T web node -e '
(async()=>{
for(const path of ["/", "/agent/index.md", "/tools/skills"]){
const r=await fetch("http://127.0.0.1:3000"+path);
if(!r.ok)throw Error(path+" returned "+r.status);
if(path.endsWith(".md")&&!r.headers.get("content-type")?.includes("text/markdown"))throw Error("Expected Markdown");
}
})().catch(e=>{console.error(e.message);process.exit(1)})'
ln -sfn "$RELEASE" "$ROOT/.current.next"
mv -Tf "$ROOT/.current.next" "$ROOT/current"
echo "Web-only release verified: $SHA; runtime image=$IMAGE; worker and infrastructure unchanged"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Compare to what is running, not HEAD^: include failed or skipped releases.
ROOT=${PROMPT_ROOT:-/opt/prompt}
BASE=${1:-}
if [ -z "$BASE" ] && [ -f "$ROOT/current/release.env" ]; then
BASE=$(sed -n 's/^RELEASE_SHA=//p' "$ROOT/current/release.env")
fi
if ! [[ "$BASE" =~ ^[0-9a-f]{40}$ ]] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then echo full; exit; fi
if ! git merge-base --is-ancestor "$BASE" HEAD; then echo full; exit; fi
CHANGED=0
while IFS= read -r -d '' file; do
CHANGED=1
case "$file" in
src/pages/api/*) echo full; exit;;
src/pages/*|src/components/*|src/styles/*|src/content/skills/*|public/*) ;;
*) echo full; exit;;
esac
done < <(git diff --no-renames --name-only -z "$BASE" HEAD)
if [ "$CHANGED" -eq 0 ]; then echo full; else echo web; fi
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Policy regression: a shared/API/dependency change must never take the Web shortcut.
set -Eeuo pipefail
SCRIPT=$(cd "$(dirname "$0")/.." && pwd)/scripts/release-mode.sh
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
cd "$FIXTURE"
git init -q
git config user.name 'Codex Bot'
git config user.email 'codex-bot@gitea.entroia.local'
mkdir -p src/pages/api src/components src/lib public
for file in src/pages/index.tsx src/pages/api/feed.ts src/components/Feed.tsx src/lib/model.ts package-lock.json public/llms.txt; do echo initial > "$file"; done
git add .
git commit -qm baseline
BASE=$(git rev-parse HEAD)
check(){ local expected="$1" actual; actual=$(bash "$SCRIPT" "$BASE"); test "$actual" = "$expected" || { echo "Expected $expected, received $actual"; exit 1; }; }
echo page >> src/pages/index.tsx
git commit -qam page
check web
# The comparison must include ALL changes since the deployed release, not just HEAD^.
echo shared >> src/lib/model.ts
git commit -qam shared
echo component >> src/components/Feed.tsx
git commit -qam component
check full
git reset -q --hard "$BASE"
echo api >> src/pages/api/feed.ts
git commit -qam api
check full
git reset -q --hard "$BASE"
git mv src/lib/model.ts public/model.ts
git commit -qm rename
check full
git reset -q --hard "$BASE"
echo lock >> package-lock.json
git commit -qam dependencies
check full
git reset -q --hard "$BASE"
echo markdown >> public/llms.txt
git commit -qam public-content
check web
test "$(bash "$SCRIPT" 0000000000000000000000000000000000000000)" = full
echo 'Release policy: page/public shortcut, shared/API/dependencies/rename/missing baseline fallback passed'