Add cached Web-only releases through existing Docker runtime
PROMPT Docker CI and deployment / verify-and-deploy (push) Successful in 5m36s
PROMPT Docker CI and deployment / verify-and-deploy (push) Successful in 5m36s
This commit is contained in:
+27
-1
@@ -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"
|
||||
|
||||
@@ -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 版本。
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user