fix: derive deployment version without env persistence

This commit is contained in:
hicocos committed 2026-08-27 22:04:40 +08:00
1 parent 6dc6839ff9
commit 665354ca49
7 files changed
+77 -42

No files matched your search

-5
View File
@@ -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=
+3 -3
View File
@@ -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 构建。
</details>
+2
View File
@@ -17,6 +17,8 @@
- 改进任务轮询串行化、任务快速筛选及按账户清理任务的准确性。
- 改进移动端和键盘操作、对话框异步失败反馈、上传失败原因展示及中英文界面一致性。
- 修复构建产物数据库 schema 同步,确保源码、初始化脚本与发布镜像一致。
- `.env.example` 不再包含 `IMAGE_VERSION`、`SOURCE_VERSION`、`SOURCE_REVISION`。应用版本统一从 `backend/package.json` 读取,Git 修订号在部署时读取,构建元数据只临时传给 Docker,不再持久化到 `.env`。
- `deploy/install.sh` 升级旧部署时会清理遗留版本字段,避免旧 `.env` 覆盖新版本;同时只重建和替换前后端,不重建 PostgreSQL。
### 升级
@@ -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/);
});
+8 -10
View File
@@ -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"。
+29 -8
View File
@@ -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
+20 -12
View File
@@ -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
}