Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a73c5b50 | ||
|
|
45de0ef759 | ||
|
|
65b5d95937 | ||
|
|
89a8edad88 | ||
|
|
283c1d77b4 | ||
|
|
f060f917a0 | ||
|
|
704740182a | ||
|
|
a61860fcb3 |
@@ -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:
|
||||
@@ -29,7 +33,10 @@ jobs:
|
||||
test "$(node -p 'require("./package.json").version')" = "${GITHUB_REF_NAME#v}"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check
|
||||
pnpm test
|
||||
# better-sqlite3 is a native addon; a single Vitest worker avoids a
|
||||
# Node cleanup race observed on the hosted runner while preserving
|
||||
# the complete test suite.
|
||||
pnpm test -- --pool=forks --poolOptions.forks.singleFork=true --maxWorkers=1 --minWorkers=1
|
||||
pnpm test:installer
|
||||
- name: Build Linux release
|
||||
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
|
||||
|
||||
@@ -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。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
((${#features[@]})) && print_group '新增功能' "${features[@]}"
|
||||
((${#fixes[@]})) && print_group '问题修复' "${fixes[@]}"
|
||||
((${#improvements[@]})) && print_group '优化与重构' "${improvements[@]}"
|
||||
((${#docs[@]})) && print_group '文档与测试' "${docs[@]}"
|
||||
((${#other[@]})) && 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
|
||||
@@ -169,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
|
||||
@@ -204,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" "$@"
|
||||
@@ -221,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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user