diff --git a/.env.example b/.env.example index 7055f7e..c96d299 100644 --- a/.env.example +++ b/.env.example @@ -23,11 +23,6 @@ CORS_ORIGIN=https://cloud.example.com # 例如:python3 -c "import secrets; print(secrets.token_hex(32))" DB_PASSWORD= -# 自动生成:镜像版本、源码版本与当前 Git 提交。 -IMAGE_VERSION= -SOURCE_REVISION= -SOURCE_VERSION= - # 高级覆盖:OAuth 默认分别继承 VITE_API_URL 与 CORS_ORIGIN。 # 仅多入口或特殊反代部署需要显式填写。 OAUTH_CALLBACK_BASE_URL= diff --git a/README.md b/README.md index 4c76c80..3e508ab 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ cd tg-vault ./deploy/install.sh ``` -安装向导会先检测 Docker Engine、Docker Compose 插件、Python 3 和 Git;缺少组件时,由你选择自动补全、查看手动提示或退出。环境通过后,依次输入 Web 前端 URL 和后端 API URL,确认后会在同一次运行中生成 `.env`、数据库密码、应用密钥和构建元数据,并执行 `docker compose up -d --build`。 +安装向导会先检测 Docker Engine、Docker Compose 插件、Python 3 和 Git;缺少组件时,由你选择自动补全、查看手动提示或退出。环境通过后,依次输入 Web 前端 URL 和后端 API URL,确认后会在同一次运行中生成 `.env`、数据库密码和应用密钥;版本号从 `backend/package.json` 读取,并只在本次构建命令中临时注入,不写入 `.env`。升级时只重建并替换前后端,不重建 PostgreSQL。 - **基础 Web 部署** 只需输入 Web 前端 URL 与后端 API URL;地址必须是完整的 `http(s)` origin。 @@ -101,8 +101,8 @@ cd tg-vault - **`DB_PASSWORD`** — 自动生成 64 位十六进制 PostgreSQL 密码 - **`SESSION_SECRET`** / **`STORAGE_CREDENTIALS_SECRET`** — 新安装时自动生成并保留;升级旧部署时不会覆盖 `/data/secrets` 中已有的持久密钥 -- **`SOURCE_REVISION`** — 自动读取当前 `git rev-parse HEAD` -- **`SOURCE_VERSION`** / **`IMAGE_VERSION`** — 优先读取当前 Git tag,否则读取项目版本 + +版本号不保存在 `.env`。安装时会直接从 `backend/package.json` 读取应用版本,并从当前 Git 提交读取源码修订号,只把它们临时传给 Docker 构建。 diff --git a/RELEASE_NOTES_v2.2.0.md b/RELEASE_NOTES_v2.2.0.md index 206ad04..7faa6a8 100644 --- a/RELEASE_NOTES_v2.2.0.md +++ b/RELEASE_NOTES_v2.2.0.md @@ -17,6 +17,8 @@ - 改进任务轮询串行化、任务快速筛选及按账户清理任务的准确性。 - 改进移动端和键盘操作、对话框异步失败反馈、上传失败原因展示及中英文界面一致性。 - 修复构建产物数据库 schema 同步,确保源码、初始化脚本与发布镜像一致。 +- `.env.example` 不再包含 `IMAGE_VERSION`、`SOURCE_VERSION`、`SOURCE_REVISION`。应用版本统一从 `backend/package.json` 读取,Git 修订号在部署时读取,构建元数据只临时传给 Docker,不再持久化到 `.env`。 +- `deploy/install.sh` 升级旧部署时会清理遗留版本字段,避免旧 `.env` 覆盖新版本;同时只重建和替换前后端,不重建 PostgreSQL。 ### 升级 diff --git a/backend/src/services/reproducibleBuildContract.test.ts b/backend/src/services/reproducibleBuildContract.test.ts index eb13650..dcd6ea6 100644 --- a/backend/src/services/reproducibleBuildContract.test.ts +++ b/backend/src/services/reproducibleBuildContract.test.ts @@ -20,9 +20,9 @@ function assertBeginnerFriendlyInstall(source: string): void { assert.match(source, /prompt_origin '请输入后端 API URL'/); assert.match(source, /ensure_generated_secret DB_PASSWORD/); assert.match(source, /if \[\[ "\$created_env" == true \]\]; then[\s\S]*ensure_generated_secret SESSION_SECRET[\s\S]*ensure_generated_secret STORAGE_CREDENTIALS_SECRET/); - assert.match(source, /SOURCE_REVISION=.*git rev-parse HEAD/); - assert.match(source, /SOURCE_VERSION=.*git describe/); - assert.match(source, /IMAGE_VERSION=.*SOURCE_VERSION/); + assert.match(source, /RELEASE_REVISION=.*git rev-parse HEAD/); + assert.match(source, /RELEASE_VERSION=.*python3[\s\S]*backend\/package\.json/); + assert.match(source, /env IMAGE_VERSION="\$RELEASE_VERSION"/); } test('release images use locked dependencies, pinned bases, verified yt-dlp and source labels', () => { @@ -69,5 +69,16 @@ test('installer keeps beginner input to two public origins and derives the rest' assert.match(deployGuide, /只需填写以下 2 项/); assert.match(deployGuide, /docker inspect/); assert.match(deployGuide, /assets\//); - assert.match(deployGuide, /IMAGE_VERSION.*source/); + assert.match(deployGuide, /镜像名称会使用 `source`/); +}); + +test('release metadata is derived per deployment and never persisted in user env files', () => { + for (const key of ['IMAGE_VERSION', 'SOURCE_REVISION', 'SOURCE_VERSION']) { + assert.doesNotMatch(envExample, new RegExp(`^${key}=`, 'm')); + assert.doesNotMatch(installScript, new RegExp(`upsert_env ${key}\\b`)); + } + assert.match(installScript, /remove_env_keys IMAGE_VERSION SOURCE_REVISION SOURCE_VERSION/); + assert.match(installScript, /env IMAGE_VERSION="\$RELEASE_VERSION" SOURCE_REVISION="\$RELEASE_REVISION" SOURCE_VERSION="\$RELEASE_VERSION" docker compose config --quiet/); + assert.match(installScript, /env IMAGE_VERSION="\$RELEASE_VERSION" SOURCE_REVISION="\$RELEASE_REVISION" SOURCE_VERSION="\$RELEASE_VERSION" docker compose build backend frontend/); + assert.match(installScript, /env IMAGE_VERSION="\$RELEASE_VERSION" SOURCE_REVISION="\$RELEASE_REVISION" SOURCE_VERSION="\$RELEASE_VERSION" docker compose up -d --no-build --no-deps backend frontend/); }); diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 54ca50a..5b677aa 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -11,7 +11,7 @@ TG Vault 当前采用 **Docker Compose + 宿主机 Nginx/面板反向代理**。 ## 2. 创建环境变量 -推荐先运行一次安装脚本,它会创建 `.env`、生成数据库密码和应用密钥,并写入当前源码版本信息: +推荐先运行一次安装脚本,它会创建 `.env`、生成数据库密码和应用密钥;当前源码版本只在构建命令中临时注入,不写入 `.env`: ```bash ./deploy/install.sh @@ -32,24 +32,22 @@ CORS_ORIGIN=https://cloud.example.com DB_PASSWORD=随机生成的64位十六进制密码 SESSION_SECRET=随机生成的64位十六进制密钥 STORAGE_CREDENTIALS_SECRET=随机生成的64位十六进制密钥 -SOURCE_REVISION=当前部署提交的完整 SHA -SOURCE_VERSION=当前 Git tag 或项目版本 -IMAGE_VERSION=与 SOURCE_VERSION 相同 ``` 升级已有部署时,如果 `.env` 没有 `SESSION_SECRET` 或 `STORAGE_CREDENTIALS_SECRET`,脚本不会突然写入新值覆盖 `/data/secrets` 中已有的持久密钥,避免现有 2FA 与存储凭证失效。 OAuth 默认使用 `VITE_API_URL` 作为回调来源,并使用 `CORS_ORIGIN` 的第一个地址作为前端通知来源。只有多入口或特殊反代部署才需显式设置 `OAUTH_CALLBACK_BASE_URL`、`OAUTH_FRONTEND_ORIGIN`。 -如果不使用安装脚本而直接运行 Compose,至少需要手动生成 `DB_PASSWORD`;`IMAGE_VERSION` / `SOURCE_REVISION` / `SOURCE_VERSION` 会分别回退为 `source` / `unknown` / `worktree`。 +如果不使用安装脚本而直接运行 Compose,至少需要手动生成 `DB_PASSWORD`;镜像名称会使用 `source`,镜像标签中的源码版本元数据会分别回退为 `unknown` / `worktree`。 ## 3. 构建并启动 -直接运行 Compose 时,缺失的 `IMAGE_VERSION` / `SOURCE_REVISION` / `SOURCE_VERSION` 会分别回退为 `source` / `unknown` / `worktree`。使用 `deploy/install.sh` 时,这些构建元数据会从当前 Git 工作树自动刷新。手动发布时也可自行更新: +版本号不保存在 `.env`。使用 `deploy/install.sh` 时,应用版本直接读取 `backend/package.json`,源码修订号读取当前 Git 提交,并只在本次 Compose 调用中传入。直接运行 Compose 时,镜像名称和标签元数据会回退为 `source` / `unknown` / `worktree`。手动构建时可临时传入: ```bash revision=$(git rev-parse HEAD) -# 将 .env 中 SOURCE_REVISION 更新为 $revision;IMAGE_VERSION 与 SOURCE_VERSION 必须等于发布版本。 +version="v$(node -p "require('./backend/package.json').version")" +IMAGE_VERSION="$version" SOURCE_REVISION="$revision" SOURCE_VERSION="$version" docker compose up -d --build ``` ```bash @@ -58,7 +56,7 @@ docker compose up -d --build docker compose ps ``` -数据库 schema 会在后端启动时自动检查/迁移。`postgres`、`backend`、`frontend` 都配置了健康检查;只有 `/readyz` 通过后 backend 才为 healthy。 +安装脚本先构建 `backend` 和 `frontend`,再通过 `--no-deps` 只替换这两个服务,不会重建 PostgreSQL。数据库 schema 会在后端启动时自动检查/迁移。`postgres`、`backend`、`frontend` 都配置了健康检查;只有 `/readyz` 通过后 backend 才为 healthy。 ## 4. 配置宿主机反向代理 @@ -77,7 +75,7 @@ docker compose ps git fetch origin git status --short git pull --ff-only origin main -docker compose up -d --build +./deploy/install.sh ``` 如果 `git status --short` 显示本地改动,先人工确认,不要强制覆盖。 @@ -91,7 +89,7 @@ curl -fsS http://127.0.0.1:51947/readyz docker compose logs --tail=100 backend frontend postgres expected_revision=$(git rev-parse HEAD) -expected_version=$(grep '^SOURCE_VERSION=' .env | cut -d= -f2-) +expected_version="v$(node -p "require('./backend/package.json').version")" docker inspect --format='{{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}' tg-vault-backend tg-vault-frontend # 两个容器都必须输出 "$expected_revision $expected_version"。 diff --git a/deploy/install.sh b/deploy/install.sh index efef90a..26e493f 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -264,6 +264,22 @@ PY fi } +remove_env_keys() { + local temp + temp="$(mktemp)" + python3 - .env "$temp" "$@" <<'PY' +from pathlib import Path +import sys +source, target, *keys = sys.argv[1:] +blocked = set(keys) +lines = Path(source).read_text().splitlines() if Path(source).exists() else [] +kept = [line for line in lines if line.split('=', 1)[0] not in blocked] +Path(target).write_text('\n'.join(kept) + ('\n' if kept else '')) +PY + chmod 600 "$temp" + mv "$temp" .env +} + created_env=false CURRENT_CORS_ORIGIN="" CURRENT_VITE_API_URL="" @@ -321,15 +337,20 @@ if [[ "$created_env" == true ]]; then ensure_generated_secret STORAGE_CREDENTIALS_SECRET fi -SOURCE_REVISION="$(git rev-parse HEAD 2>/dev/null || printf unknown)" -SOURCE_VERSION="$(git describe --tags --exact-match 2>/dev/null || node -p "'v' + require('./backend/package.json').version" 2>/dev/null || printf worktree)" -IMAGE_VERSION="$SOURCE_VERSION" -upsert_env SOURCE_REVISION "$SOURCE_REVISION" -upsert_env SOURCE_VERSION "$SOURCE_VERSION" -upsert_env IMAGE_VERSION "$IMAGE_VERSION" +# v2.2.0 及更早版本曾把构建元数据写入 .env,升级时主动清理,避免旧值覆盖新版本。 +remove_env_keys IMAGE_VERSION SOURCE_REVISION SOURCE_VERSION -docker compose config --quiet -docker compose up -d --build +RELEASE_REVISION="$(git rev-parse HEAD 2>/dev/null || printf unknown)" +RELEASE_VERSION="v$(python3 - <<'PY' +import json +from pathlib import Path +print(json.loads(Path('backend/package.json').read_text())['version']) +PY +)" + +env IMAGE_VERSION="$RELEASE_VERSION" SOURCE_REVISION="$RELEASE_REVISION" SOURCE_VERSION="$RELEASE_VERSION" docker compose config --quiet +env IMAGE_VERSION="$RELEASE_VERSION" SOURCE_REVISION="$RELEASE_REVISION" SOURCE_VERSION="$RELEASE_VERSION" docker compose build backend frontend +env IMAGE_VERSION="$RELEASE_VERSION" SOURCE_REVISION="$RELEASE_REVISION" SOURCE_VERSION="$RELEASE_VERSION" docker compose up -d --no-build --no-deps backend frontend docker compose ps echo diff --git a/deploy/install.test.sh b/deploy/install.test.sh index 5d4fadc..6af355a 100755 --- a/deploy/install.test.sh +++ b/deploy/install.test.sh @@ -60,7 +60,8 @@ test_interactive_new_install_collects_urls_and_starts_compose() { assert_contains "$FIXTURE/output.log" '请输入后端 API URL' assert_contains "$FIXTURE/output.log" '按 Enter 保存配置并开始安装' assert_contains "$FIXTURE/docker.log" 'compose config --quiet' - assert_contains "$FIXTURE/docker.log" 'compose up -d --build' + assert_contains "$FIXTURE/docker.log" 'compose build backend frontend' + assert_contains "$FIXTURE/docker.log" 'compose up -d --no-build --no-deps backend frontend' assert_contains "$FIXTURE/docker.log" 'compose ps' } @@ -86,7 +87,8 @@ test_non_interactive_uses_environment_without_waiting() { assert_contains "$FIXTURE/.env" 'CORS_ORIGIN=https://cloud.example.net' assert_contains "$FIXTURE/.env" 'VITE_API_URL=https://api.example.net' - assert_contains "$FIXTURE/docker.log" 'compose up -d --build' + assert_contains "$FIXTURE/docker.log" 'compose build backend frontend' + assert_contains "$FIXTURE/docker.log" 'compose up -d --no-build --no-deps backend frontend' } test_existing_install_keeps_urls_on_enter() { @@ -97,6 +99,9 @@ VITE_API_URL=https://existing-api.example.net DB_PASSWORD=keep-this-password SESSION_SECRET=keep-this-session STORAGE_CREDENTIALS_SECRET=keep-this-storage-secret +IMAGE_VERSION=v1.0.0 +SOURCE_REVISION=stale-revision +SOURCE_VERSION=v1.0.0 EOF run_in_tty '\n\n\n' "$FIXTURE/output.log" @@ -105,6 +110,9 @@ EOF assert_contains "$FIXTURE/.env" 'DB_PASSWORD=keep-this-password' assert_contains "$FIXTURE/.env" 'SESSION_SECRET=keep-this-session' assert_contains "$FIXTURE/.env" 'STORAGE_CREDENTIALS_SECRET=keep-this-storage-secret' + if grep -Eq '^(IMAGE_VERSION|SOURCE_REVISION|SOURCE_VERSION)=' "$FIXTURE/.env"; then + fail "升级后应清理旧版持久化版本元数据" + fi assert_contains "$FIXTURE/output.log" '直接按 Enter 保留当前值' } @@ -138,24 +146,24 @@ SH test_secret_generation_does_not_require_openssl() { make_fixture - hidden="$FIXTURE/hidden-bin" - mkdir -p "$hidden" - for cmd in python3 git node mktemp chmod mv bash dirname id touch; do - target="$(command -v "$cmd")" - ln -s "$target" "$hidden/$cmd" - done - ln -s "$FIXTURE/fake-bin/docker" "$hidden/docker" + openssl_stub="$FIXTURE/fake-bin/openssl" + cat > "$openssl_stub" <<'SH' +#!/usr/bin/env bash +echo "openssl 不应被调用" >&2 +exit 99 +SH + chmod +x "$openssl_stub" ( cd "$FIXTURE" - env PATH="$hidden" \ + env PATH="$FIXTURE/fake-bin:$PATH" \ INSTALL_TEST_DOCKER_LOG="$FIXTURE/docker.log" \ CORS_ORIGIN='https://cloud.example.net' \ VITE_API_URL='https://api.example.net' \ bash deploy/install.sh --non-interactive > "$FIXTURE/output.log" 2>&1 ) assert_contains "$FIXTURE/.env" 'DB_PASSWORD=' - if grep -Fq '未找到 openssl' "$FIXTURE/output.log"; then - fail "安装脚本不应再要求 openssl" + if grep -Fq 'openssl 不应被调用' "$FIXTURE/output.log"; then + fail "安装脚本不应调用 openssl" fi }