diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6473529..778d127 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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" diff --git a/docs/ci-and-entrypoints.md b/docs/ci-and-entrypoints.md index 736a73a..771eadc 100644 --- a/docs/ci-and-entrypoints.md +++ b/docs/ci-and-entrypoints.md @@ -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/.`,只读挂载到 Web 容器;仅重建 Web,保留 runtime 镜像、worker、Postgres 和 RustFS。切换前检查 main 与当前发布,部署锁与全量流程共用;失败自动恢复旧 Web Compose 配置。发布文件保留变量表达式,不写入生产秘密。健康检查通过后切换 current;原完整发布也能回滚到 Web 快速发布。 + +首次接入流水线变更仍走完整验证;后续 Web 更新速度需要实测,不能承诺 HMR 的毫秒级反馈。每次发布完整 Web 产物,不把个别 chunk 混入正在运行的 Next.js 版本。 diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh new file mode 100644 index 0000000..b09c70b --- /dev/null +++ b/scripts/deploy-web.sh @@ -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" < "$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" diff --git a/scripts/release-mode.sh b/scripts/release-mode.sh new file mode 100644 index 0000000..2f7dcf1 --- /dev/null +++ b/scripts/release-mode.sh @@ -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 diff --git a/tests/release-mode.sh b/tests/release-mode.sh new file mode 100644 index 0000000..4634531 --- /dev/null +++ b/tests/release-mode.sh @@ -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'