Compare commits

...
5 Commits
Author SHA1 Message Date
Qiufeng 283c1d77b4 fix: generate detailed markdown release notes
TallyNote release / linux-x64 (push) Failing after 8m37s
2026-09-04 10:43:45 +08:00
Qiufeng f060f917a0 release: 1.1.26
TallyNote release / linux-x64 (push) Successful in 6m41s
2026-09-04 01:13:42 +08:00
Qiufeng 704740182a fix: hide stale update failures on status load 2026-09-04 01:08:46 +08:00
Qiufeng a61860fcb3 refactor: move update pipeline into dialog 2026-09-04 01:02:12 +08:00
Qiufeng 340d9b5245 feat: add lightweight application updates
TallyNote release / linux-x64 (push) Failing after 8m43s
2026-09-03 23:32:16 +08:00
13 changed files with 299 additions and 45 deletions
+4
View File
@@ -17,6 +17,10 @@ jobs:
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
# Release notes are derived from the previous version tag. A shallow
# checkout would leave only the synthetic release commit available.
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
+4 -2
View File
@@ -12,6 +12,8 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
发布脚本会根据当前 tag 与上一个版本 tag 之间的真实 Git 提交自动生成 Release 正文,按“新增功能、问题修复、优化与重构、文档与测试”分类,并以 Markdown 写入 Gitea。Gitea 页面会渲染这些标题和列表;更新中心读取同一份正文后再进行安全的 Markdown 子集渲染,不会显示 Markdown 源代码。旧版本曾使用单行占位正文 `TallyNote <版本>`,新版本发布时不会再使用该占位内容。
在仓库的 Actions secrets 配置:
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
@@ -28,7 +30,7 @@ GITEA_TOKEN=... \
./scripts/publish-gitea-release.sh v1.1.2 ./release
```
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
## curl 安装
@@ -104,7 +106,7 @@ sudo /usr/local/sbin/tallynote-uninstall
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.1.24",
"version": "1.1.27",
"private": true,
"type": "module",
"packageManager": "pnpm@9.0.6",
+25 -1
View File
@@ -27,7 +27,11 @@ pnpm build
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
cp -a dist/. "$stage/dist/"
# Copy only the production build outputs. In particular, do not carry a
# stale dist/web-next directory from a previous local preview build.
cp -a dist/server "$stage/dist/"
cp -a dist/shared "$stage/dist/"
cp -a dist/web "$stage/dist/"
cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
@@ -46,6 +50,26 @@ find "$stage" -type l -delete
mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# The application-only asset is used by the online updater. It deliberately
# excludes the stable runtime (Node and production dependencies), systemd
# helpers and installer files; the updater overlays it on the currently
# installed, already-validated runtime before atomically switching releases.
app_stage=$(mktemp -d)
trap 'rm -rf "$stage" "$app_stage"' EXIT
mkdir -p "$app_stage/dist" "$app_stage/migrations" "$app_stage/bin" "$app_stage/scripts" "$app_stage/systemd"
cp -a "$stage/dist/server" "$app_stage/dist/"
cp -a "$stage/dist/shared" "$app_stage/dist/"
cp -a "$stage/dist/web" "$app_stage/dist/"
cp -a "$stage/migrations/." "$app_stage/migrations/"
cp -a "$stage/bin/." "$app_stage/bin/"
cp -a "$stage/scripts/." "$app_stage/scripts/"
cp -a "$stage/systemd/." "$app_stage/systemd/"
cp "$stage/package.json" "$app_stage/package.json"
cp "$stage/uninstall.sh" "$app_stage/uninstall.sh"
runtime_hash=$(sha256sum pnpm-lock.yaml | awk '{print $1}')
app_archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.update-${runtime_hash}.tar.gz"
tar -C "$app_stage" -czf "$app_archive" --owner=0 --group=0 --numeric-owner .
# Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release.
+104 -3
View File
@@ -24,6 +24,7 @@ DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
RELEASE_NOTES_TMP=''
SIGNATURE_GENERATED=0
usage() {
@@ -43,6 +44,81 @@ EOF
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
generate_release_notes() {
local current=${TAG#v} previous='' subject kind line count=0
local -a commits
commits=()
# A workflow checks out the tag with history. Prefer an explicitly supplied
# notes file for mirrors, then derive notes from the immutable tag range.
if [[ -n "${TALLYNOTE_RELEASE_NOTES_FILE:-}" && -f "$TALLYNOTE_RELEASE_NOTES_FILE" ]]; then
# Read at most the API's bounded notes size without a pipe that can turn a
# deliberately truncated input into a SIGPIPE failure under pipefail.
LC_ALL=C awk 'BEGIN { remaining = 65536 } { if (remaining <= 0) exit; line=$0; gsub(/[[:cntrl:]]/, "", line); bytes=length(line)+1; if (bytes > remaining) { print substr(line, 1, remaining); exit } print line; remaining-=bytes }' "$TALLYNOTE_RELEASE_NOTES_FILE"
return
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "$line" == "v${current}" ]] && continue
previous="$line"
break
done < <(git tag --sort=-version:refname --list 'v*')
if [[ -n "$previous" && "$previous" != "v${current}" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log --format='%s' "${previous}..${TAG}")
else
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log -n 30 --format='%s' "$TAG")
fi
fi
printf '# TallyNote %s\n\n' "$current"
if [[ -n "$previous" ]]; then
printf '> 从 `%s` 到 `%s` 的变更\n\n' "$previous" "v${current}"
else
printf '> 本版本变更\n\n'
fi
local -a features fixes improvements docs other
features=(); fixes=(); improvements=(); docs=(); other=()
for subject in "${commits[@]-}"; do
# Do not expose merge noise or the synthetic release commit in user notes.
[[ "$subject" != Merge\ * && "$subject" != release:* ]] || continue
kind=${subject%%:*}
if [[ "$subject" == *:* ]]; then subject=${subject#*: }; fi
subject=${subject# }
[[ -n "$subject" ]] || continue
case "$kind" in
feat|feature) features+=("$subject") ;;
fix|bugfix) fixes+=("$subject") ;;
refactor|perf|style|improvement) improvements+=("$subject") ;;
docs|doc|test|tests) docs+=("$subject") ;;
*) other+=("$subject") ;;
esac
done
print_group() {
local title=$1; shift
local item
(($# > 0)) || return 0
printf '## %s\n\n' "$title"
for item in "$@"; do printf -- '- %s\n' "$item"; done
printf '\n'
}
print_group '新增功能' "${features[@]-}"
print_group '问题修复' "${fixes[@]-}"
print_group '优化与重构' "${improvements[@]-}"
print_group '文档与测试' "${docs[@]-}"
print_group '其他变更' "${other[@]-}"
if (( ${#features[@]} + ${#fixes[@]} + ${#improvements[@]} + ${#docs[@]} + ${#other[@]} == 0 )); then
printf '本版本包含内部维护更新。\n'
fi
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
@@ -128,7 +204,8 @@ fi
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
assets=()
full_assets=()
update_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
@@ -136,9 +213,16 @@ for file in "$ASSET_DIR"/*.tar.gz; do
asset_version=${name#tallynote-}
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
assets+=("$file")
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
update_assets+=("$file")
else
full_assets+=("$file")
fi
done
assets=("${full_assets[@]}")
if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
@@ -161,6 +245,7 @@ cleanup() {
if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi
if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi
if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi
if [[ -n "$RELEASE_NOTES_TMP" ]]; then rm -f -- "$RELEASE_NOTES_TMP"; fi
}
trap cleanup EXIT
if [[ -n "$SIGNING_KEY_FILE" ]]; then
@@ -196,6 +281,13 @@ command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
# Keep the release body deterministic and human-readable. Gitea renders this
# Markdown in the Release page; the update API later exposes the same body as
# text for the safe client-side Markdown renderer.
RELEASE_NOTES_TMP=$(mktemp)
generate_release_notes > "$RELEASE_NOTES_TMP"
release_notes=$(<"$RELEASE_NOTES_TMP")
api_curl() {
"$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
@@ -213,8 +305,17 @@ release_json=$(mktemp)
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release'
if [[ "$status" == 200 ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
existing_body=$(jq -r '.body // ""' "$release_json")
# Older releases used a one-line placeholder. Upgrade that placeholder when
# a tag is republished, while leaving deliberately authored release notes
# untouched.
if [[ "$existing_body" == "TallyNote $TAG" || -z "$existing_body" ]]; then
patch_body=$(jq -cn --arg body "$release_notes" '{body:$body}')
patch_status=$(api_curl_status -X PATCH -H 'Content-Type: application/json' -d "$patch_body" -o /dev/null -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/$release_id") || die '无法更新 Gitea Release 日志'
[[ "$patch_status" == 2* ]] || die "无法更新 Gitea Release 日志(HTTP $patch_status)"
fi
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "$release_notes" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release'
if [[ "$create_status" == 2* ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
+9 -2
View File
@@ -930,6 +930,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
// Status is a live control surface, not an update history endpoint.
// Terminal failures/cancellations from a previous attempt must not be
// replayed as if the operator had just started an update. They remain in
// the database/audit log, while this endpoint exposes only an actionable
// task (or the latest successful completion for confirmation).
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
@@ -937,8 +942,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
FROM update_jobs
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
return {
...cached,
strategy: config.updateStrategy,
+22 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
@@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
applicationUpdateRuntimeHash,
compareSemver,
createSafeArchive,
detectPlatform,
@@ -19,6 +20,7 @@ import {
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
runtimeHashFromLockfile,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
@@ -212,9 +214,16 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
} catch {
// Fall back to the full archive when the current installation predates
// runtime fingerprints or is missing deployment provenance.
}
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform);
: selectReleaseAsset(release, platform, runtimeHash);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
@@ -314,6 +323,17 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
const currentInfo = await lstat(currentRelease).catch(() => null);
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry);
const sourceInfo = await lstat(source).catch(() => null);
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
}
}
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
+9 -1
View File
@@ -13,6 +13,7 @@ import {
fetchReleaseText,
isNewerVersion,
parseSemver,
runtimeHashFromLockfile,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
@@ -204,7 +205,14 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let asset = selectReleaseAsset(metadata, platform);
let runtimeHash: string | undefined;
try {
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
} catch {
// Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime.
}
let asset = selectReleaseAsset(metadata, platform, runtimeHash);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
+20 -2
View File
@@ -43,6 +43,16 @@ export type ReleaseMetadata = {
assets: ReleaseAsset[];
};
const APPLICATION_UPDATE_ASSET = /\.update-([a-f0-9]{64})\.tar\.gz$/i;
export function applicationUpdateRuntimeHash(assetName: string): string | undefined {
return APPLICATION_UPDATE_ASSET.exec(assetName)?.[1]?.toLowerCase();
}
export function runtimeHashFromLockfile(lockfile: string | Buffer): string {
return createHash("sha256").update(lockfile).digest("hex");
}
export type UrlPolicy = {
/** Host names or HTTPS URLs which are allowed for requests. */
allowedHosts?: readonly string[] | undefined;
@@ -379,7 +389,7 @@ export async function fetchReleaseBytes(
}
}
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined {
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined {
const platformCandidates = release.assets.filter((asset) => {
const name = asset.name.toLowerCase();
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase()));
@@ -398,7 +408,15 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
const target = platform.target.toLowerCase();
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
});
return candidates[0];
const normalizedRuntimeHash = runtimeHash?.trim().toLowerCase();
if (normalizedRuntimeHash && /^[a-f0-9]{64}$/.test(normalizedRuntimeHash)) {
const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash);
if (applicationUpdate) return applicationUpdate;
}
// Older clients choose the first matching asset. Releases therefore keep
// the traditional full archive first, while current clients explicitly
// opt into a compatible application-only asset.
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
}
export function sanitizeAssetName(value: string): string {
+18
View File
@@ -137,6 +137,24 @@ describe("更新 API", () => {
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
const session = await login("update-history");
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(initial.statusCode).toBe(200);
expect(initial.json().job).toBeNull();
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.2.0", isNewer: true });
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
const owner = await login("update-owner");
const other = await login("update-other");
+13
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto";
import {
atomicSwitchRelease,
applicationUpdateRuntimeHash,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
@@ -16,6 +17,7 @@ import {
normalizeReleasePermissions,
sanitizeAssetName,
selectReleaseAsset,
runtimeHashFromLockfile,
validateHttpsUrl,
} from "../server/update.js";
import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js";
@@ -50,6 +52,17 @@ describe("更新安全工具", () => {
expect(() => sanitizeAssetName("../release.tar.gz")).toThrow();
});
it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => {
const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n");
const full = { name: "tallynote-1.2.0-linux-x64-glibc.tar.gz", url: "https://updates.example/full" };
const app = { name: `tallynote-1.2.0-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" };
const release = { version: "1.2.0", assets: [full, app] };
expect(applicationUpdateRuntimeHash(app.name)).toBe(runtimeHash);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"), runtimeHash)).toEqual(app);
expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))).toEqual(full);
expect(applicationUpdateRuntimeHash(full.name)).toBeUndefined();
});
it("验证 SHA256SUMS 的 Ed25519 detached signature", () => {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const payload = "a".repeat(64) + " tallynote.tar.gz\n";
+51 -30
View File
@@ -273,6 +273,7 @@ export default function UpdatePage({
const [actionBusy, setActionBusy] = useState(false);
const [reloadReady, setReloadReady] = useState(false);
const [showNotesDialog, setShowNotesDialog] = useState(false);
const [showPipelineDialog, setShowPipelineDialog] = useState(false);
const [now, setNow] = useState(() => Date.now());
const announced = useRef<string | null>(null);
@@ -337,7 +338,14 @@ export default function UpdatePage({
else setLoading(false);
}, [isMock]);
const job = info?.job;
// The status endpoint intentionally hides terminal failures/cancellations.
// Keep this guard in the UI as well so a stale response from an older
// server cannot turn a fresh page visit into a false failure state.
const job = info?.job && info.job.status !== "failed" && info.job.status !== "cancelled" ? info.job : null;
useEffect(() => {
if (job && activeStatuses.has(job.status)) setShowPipelineDialog(true);
}, [job?.id, job?.status]);
const applyQueuedAt = timestamp(job?.applyQueuedAt);
const restartAt =
timestamp(job?.restartDeadline) ??
@@ -436,7 +444,10 @@ export default function UpdatePage({
method: "POST",
body: "{}",
});
setLiveInfo((current) => ({ ...result, job: result.job ?? current?.job ?? null }));
setLiveInfo((current) => ({
...result,
job: result.job ?? (current?.job && activeStatuses.has(current.job.status) ? current.job : null),
}));
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
} catch (caught) {
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
@@ -468,6 +479,7 @@ export default function UpdatePage({
mockTimer.current = null;
setActionBusy(false);
setConfirmVersion(null);
setShowPipelineDialog(true);
if (confirmAction === "download") {
handleScenarioChange("downloading_30");
notify?.("Mock:开始模拟下载,状态已流转至 [下载中]", "info");
@@ -496,6 +508,7 @@ export default function UpdatePage({
setConfirmVersion(null);
setReloadReady(false);
setLiveInfo((current) => (current ? { ...current, job: result.job } : current));
setShowPipelineDialog(true);
notify?.(
confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启",
"info"
@@ -715,11 +728,18 @@ export default function UpdatePage({
</Surface>
</div>
{/* Stepper Process Card */}
<Surface className="tn-stepper-surface">
{/* Stepper Process Dialog */}
<Dialog
visible={showPipelineDialog}
header="版本升级流水线"
confirmBtn={{ content: "关闭", theme: "default" }}
cancelBtn={null}
onConfirm={() => setShowPipelineDialog(false)}
onClose={() => setShowPipelineDialog(false)}
>
<div className="tn-stepper-surface">
<div className="tn-stepper-head">
<div>
<h3 className="tn-section-heading">版本升级流水线</h3>
<small className="tn-section-subheading">
标准化发布流程:版本发现 → 校验签名与清单 → 安全暂存 → 原子切换并重启
</small>
@@ -834,7 +854,8 @@ export default function UpdatePage({
<span>更新成功完成!新版本已通过内置端点健康检查,账本数据与附件完整无损。</span>
</div>
)}
</Surface>
</div>
</Dialog>
{/* Release Details Section */}
{latest ? (
@@ -856,32 +877,8 @@ export default function UpdatePage({
</small>
)}
</div>
<div className="tn-release-header-actions">
{notes && (
<Button
variant="outline"
size="medium"
onClick={() => setShowNotesDialog(true)}
icon={<FileCode size={15} />}
>
查看完整更新日志
</Button>
)}
</div>
</div>
{/* Release Notes Preview snippet */}
{notes && (
<div className="tn-release-notes-box">
<div className="tn-release-notes-heading">
<span>更新内容概览</span>
</div>
<div className="tn-release-notes-content">
<MarkdownNotes value={notes} compact />
</div>
</div>
)}
{/* Integrity & compatibility facts grid */}
<div className="tn-facts-grid">
<div className="tn-fact-item">
@@ -963,6 +960,30 @@ export default function UpdatePage({
</Surface>
)}
{latest && notes && (
<Surface className="tn-release-notes-surface">
<div className="tn-release-notes-header">
<div>
<span className="tn-eyebrow">更新日志</span>
<h3 className="tn-section-heading">本次版本更新内容</h3>
</div>
<Button
variant="outline"
size="medium"
onClick={() => setShowNotesDialog(true)}
icon={<FileCode size={15} />}
>
查看完整日志
</Button>
</div>
<div className="tn-release-notes-box">
<div className="tn-release-notes-content">
<MarkdownNotes value={notes} compact />
</div>
</div>
</Surface>
)}
{/* Manual source warning if not systemd */}
{!info.configured && (
<div className="tn-update-explainer">
+19 -1
View File
@@ -847,6 +847,17 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
margin-bottom: 16px;
padding: 20px;
}
.tn-release-notes-surface {
margin-bottom: 16px;
padding: 20px;
}
.tn-release-notes-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.tn-release-header {
display: flex;
align-items: flex-start;
@@ -870,7 +881,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
color: var(--tn-text-secondary);
}
.tn-release-notes-box {
margin: 16px 0;
margin: 0;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid var(--tn-border);
@@ -1064,6 +1075,13 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
.tn-release-header {
flex-direction: column;
}
.tn-release-notes-header {
align-items: flex-start;
flex-direction: column;
}
.tn-release-notes-header .t-button {
width: 100%;
}
.tn-release-card-actions {
flex-direction: column;
width: 100%;