Compare commits

..
Author SHA1 Message Date
Qiufeng 6b04d8dd0a feat: show signed release changelog in update history
Release / release (push) Canceled after 0s
2026-08-19 08:27:08 +08:00
15 changed files with 190 additions and 243 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ jobs:
RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }} RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_RELEASE_NOTES: Kaidi Finance ${{ steps.release_meta.outputs.ref }} KAIDI_REQUIRE_RELEASE_NOTES: 'true'
KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }} KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }} KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
KAIDI_SOURCE_DIRTY: 'false' KAIDI_SOURCE_DIRTY: 'false'
+12 -10
View File
@@ -83,7 +83,7 @@ PostgreSQL 18 兼容工作继续冻结。
先在宝塔面板停止并删除当前错误的 Java 项目,再执行: 先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/install.sh | sudo env KAIDI_APP_PORT=18080 bash curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/install.sh | sudo env KAIDI_APP_PORT=18080 bash
``` ```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -96,7 +96,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理: 随后执行一键清理:
```bash ```bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
``` ```
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。 上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
@@ -117,9 +117,9 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员
下载地址: 下载地址:
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/kaidi-finance-1.0.0-preview.46.tar.gz` `https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/kaidi-finance-1.0.0-preview.47.tar.gz`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/SHA256SUMS` 校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/SHA256SUMS`
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要 服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
@@ -136,7 +136,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash ```bash
VERSION=1.0.0-preview.46 VERSION=1.0.0-preview.47
APP_ROOT=/www/wwwroot/kaidi APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION" RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT" sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -245,7 +245,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除: `KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
```bash ```bash
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
``` ```
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
@@ -268,11 +268,13 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-notes/<version>.md` 写好面向用户的更新日志。
打包器会把同一份内容写入签名 `release-manifest.json`,后台历史从该清单读取;发布脚本也会用它生成
Gitea Release 正文,避免三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.46 git tag v1.0.0-preview.47
git push origin v1.0.0-preview.46 git push origin v1.0.0-preview.47
``` ```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
@@ -324,7 +326,7 @@ cat /var/lib/kaidi-update/status.json
```bash ```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.46 ./scripts/package-release.sh 1.0.0-preview.47
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release ./scripts/verify-release.sh dist/release
``` ```
@@ -77,6 +77,18 @@ public class AuditService {
*/ */
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state, public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt) { String targetVersion, String message, Instant updatedAt) {
return recordSystemUpdateTerminal(updateRequestId, updateAction, state, targetVersion, message, updatedAt,
null, null);
}
/**
* Persists a terminal update event together with the release metadata that was verified before the switch.
* Keeping the signed release notes in the audit snapshot lets the history page work after a restart or when
* the release host is temporarily unavailable.
*/
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt,
String releaseNotes, Instant publishedAt) {
String normalizedState = normalizeTerminalState(state); String normalizedState = normalizeTerminalState(state);
String normalizedVersion = clean(targetVersion, 128); String normalizedVersion = clean(targetVersion, 128);
if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null; if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null;
@@ -110,6 +122,9 @@ public class AuditService {
terminal.put("targetVersion", normalizedVersion); terminal.put("targetVersion", normalizedVersion);
terminal.put("message", terminalReason == null ? "" : terminalReason); terminal.put("message", terminalReason == null ? "" : terminalReason);
terminal.put("updatedAt", updatedAt); terminal.put("updatedAt", updatedAt);
String normalizedNotes = clean(releaseNotes, 4000);
if (normalizedNotes != null) terminal.put("releaseNotes", normalizedNotes);
if (publishedAt != null) terminal.put("publishedAt", publishedAt);
if (normalizedAction != null) terminal.put("action", normalizedAction); if (normalizedAction != null) terminal.put("action", normalizedAction);
if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId); if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId);
@@ -444,7 +444,8 @@ public class SystemUpdateApplicationService {
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"), nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100), nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300), boundedInteger(root, "restartExpectedSeconds", 0, 300),
boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null))); boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)),
boundedText(root, "releaseNotes", 4000), parseInstant(root.path("publishedAt").asText(null)));
} catch (IOException exception) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
} }
@@ -466,9 +467,13 @@ public class SystemUpdateApplicationService {
&& authorizationService.hasPermission("admin:update:execute")) { && authorizationService.hasPermission("admin:update:execute")) {
actions.add(ready ? "INSTALL" : "DOWNLOAD"); actions.add(ready ? "INSTALL" : "DOWNLOAD");
} }
String releaseNotes = manifest != null && manifest.releaseNotes() != null && !manifest.releaseNotes().isBlank()
? manifest.releaseNotes() : status.releaseNotes();
Instant publishedAt = manifest != null && manifest.publishedAt() != null
? manifest.publishedAt() : status.publishedAt();
return new SystemUpdateView(enabled, current, candidateVersion, return new SystemUpdateView(enabled, current, candidateVersion,
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(), available, status.state(), status.message(), releaseNotes,
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), publishedAt, lastCheckedAt, status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(), status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions)); status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions));
} }
@@ -672,7 +677,8 @@ public class SystemUpdateApplicationService {
if (fingerprint.equals(lastTerminalAuditFingerprint)) return; if (fingerprint.equals(lastTerminalAuditFingerprint)) return;
try { try {
String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(), String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(),
status.state(), status.targetVersion(), status.message(), status.updatedAt()); status.state(), status.targetVersion(), status.message(), status.updatedAt(),
status.releaseNotes(), status.publishedAt());
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint; if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(), log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
@@ -699,14 +705,15 @@ public class SystemUpdateApplicationService {
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond, Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds, Integer downloadPercent, Integer restartExpectedSeconds,
String requestId, String action) { String requestId, String action, String releaseNotes, Instant publishedAt) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null); this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null, null, null);
} }
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
String requestId, String action) { String requestId, String action) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action); this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action,
null, null);
} }
} }
@@ -27,7 +27,8 @@ class AuditServiceTest {
"01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST")) "01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST"))
.thenReturn(new AuditMapper.SystemUpdateAuditSource( .thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN", "01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}", null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\","
+ "\"releaseNotes\":\"Preview update\"}",
"127.0.0.1", "fixture-agent")); "127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1); when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(), AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
@@ -35,9 +36,11 @@ class AuditServiceTest {
Instant completedAt = Instant.parse("2026-08-19T00:10:00Z"); Instant completedAt = Instant.parse("2026-08-19T00:10:00Z");
String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt,
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt); "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt,
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
assertEquals(firstKey, secondKey); assertEquals(firstKey, secondKey);
assertTrue(firstKey.startsWith("SYSUPD:")); assertTrue(firstKey.startsWith("SYSUPD:"));
@@ -52,6 +55,8 @@ class AuditServiceTest {
assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED")); assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED"));
assertTrue(persisted.afterJson().contains("1.0.0-preview.44")); assertTrue(persisted.afterJson().contains("1.0.0-preview.44"));
assertTrue(persisted.afterJson().contains("SUCCEEDED")); assertTrue(persisted.afterJson().contains("SUCCEEDED"));
assertTrue(persisted.afterJson().contains("Preview update"));
assertTrue(persisted.afterJson().contains("publishedAt"));
} }
@Test @Test
@@ -294,21 +294,26 @@ class SystemUpdateApplicationServiceTest {
Path statusFile = tempDir.resolve("terminal-status.json"); Path statusFile = tempDir.resolve("terminal-status.json");
Files.writeString(statusFile, """ Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44", {"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
"updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092", "updatedAt":"2026-08-19T00:10:00Z","releaseNotes":"Preview update",
"publishedAt":"2026-08-16T00:00:00Z","requestId":"01M00000000000000000000092",
"action":"INSTALL"} "action":"INSTALL"}
"""); """);
AuditService auditService = mock(AuditService.class); AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED", when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED",
"1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z"))) "1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z"),
"Preview update", java.time.Instant.parse("2026-08-16T00:00:00Z")))
.thenReturn("SYSUPD:terminal"); .thenReturn("SYSUPD:terminal");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state()); assertEquals("SUCCEEDED", service.status().state());
assertEquals("Preview update", service.status().releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), service.status().publishedAt());
assertEquals("SUCCEEDED", service.status().state()); assertEquals("SUCCEEDED", service.status().state());
verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", "SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查",
java.time.Instant.parse("2026-08-19T00:10:00Z")); java.time.Instant.parse("2026-08-19T00:10:00Z"), "Preview update",
java.time.Instant.parse("2026-08-16T00:00:00Z"));
} }
@Test @Test
@@ -321,14 +326,14 @@ class SystemUpdateApplicationServiceTest {
"""); """);
AuditService auditService = mock(AuditService.class); AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"))) "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null))
.thenReturn("SYSUPD:legacy"); .thenReturn("SYSUPD:legacy");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService); SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state()); assertEquals("SUCCEEDED", service.status().state());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43", verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")); "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null);
} }
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) { private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
@@ -382,6 +387,8 @@ class SystemUpdateApplicationServiceTest {
var checked = service.check(); var checked = service.check();
assertTrue(checked.updateAvailable()); assertTrue(checked.updateAvailable());
assertEquals("1.0.0-preview.2+build.7", checked.latestVersion()); assertEquals("1.0.0-preview.2+build.7", checked.latestVersion());
assertEquals("Preview update", checked.releaseNotes());
assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), checked.publishedAt());
assertTrue(checked.allowedActions().contains("DOWNLOAD")); assertTrue(checked.allowedActions().contains("DOWNLOAD"));
assertFalse(checked.allowedActions().contains("INSTALL")); assertFalse(checked.allowedActions().contains("INSTALL"));
+18
View File
@@ -43,6 +43,8 @@ RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION= TARGET_VERSION=
REQUEST_ID= REQUEST_ID=
REQUEST_ACTION= REQUEST_ACTION=
RELEASE_NOTES=
RELEASE_PUBLISHED_AT=
TERMINAL_STATUS_WRITTEN=false TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID= DOWNLOAD_PID=
DOWNLOAD_PGID= DOWNLOAD_PGID=
@@ -297,6 +299,8 @@ status() {
previous_message= previous_message=
previous_request_id= previous_request_id=
previous_action= previous_action=
previous_release_notes=
previous_published_at=
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true) previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true) previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
@@ -304,6 +308,10 @@ status() {
"$STATUS_FILE" 2>/dev/null || true) "$STATUS_FILE" 2>/dev/null || true)
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \ previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true) | select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
previous_release_notes=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
"$STATUS_FILE" 2>/dev/null || true)
previous_published_at=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
"$STATUS_FILE" 2>/dev/null || true)
fi fi
status_request_id= status_request_id=
status_action= status_action=
@@ -315,6 +323,8 @@ status() {
fi fi
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id} [ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action} [ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
status_release_notes=${RELEASE_NOTES:-$previous_release_notes}
status_published_at=${RELEASE_PUBLISHED_AT:-$previous_published_at}
tmp="$STATUS_FILE.tmp.$$" tmp="$STATUS_FILE.tmp.$$"
jq -n \ jq -n \
--arg state "$state" \ --arg state "$state" \
@@ -327,11 +337,15 @@ status() {
--arg restartExpectedSeconds "$restart_expected_seconds" \ --arg restartExpectedSeconds "$restart_expected_seconds" \
--arg requestId "$status_request_id" \ --arg requestId "$status_request_id" \
--arg action "$status_action" \ --arg action "$status_action" \
--arg releaseNotes "$status_release_notes" \
--arg publishedAt "$status_published_at" \
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt} '{state:$state,message:$message,updatedAt:$updatedAt}
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end) + (if ($version | length) > 0 then {targetVersion:$version} else {} end)
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end) + (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end) + (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
+ (if ($releaseNotes | length) > 0 then {releaseNotes:$releaseNotes} else {} end)
+ (if ($publishedAt | length) > 0 then {publishedAt:$publishedAt} else {} end)
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end) + (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end) + (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end) + (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
@@ -1119,6 +1133,10 @@ EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-
"$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid" "$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid"
EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \ EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \
"$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid" "$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid"
RELEASE_NOTES=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
"$WORK_DIR/release-manifest.json") || fail "Release notes are invalid"
RELEASE_PUBLISHED_AT=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
"$WORK_DIR/release-manifest.json") || fail "Release publish time is invalid"
CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true) CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true)
if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then
+16 -8
View File
@@ -138,8 +138,12 @@ async function installFixture(
beforeJson: null, beforeJson: null,
afterJson: JSON.stringify({ afterJson: JSON.stringify({
targetVersion, targetVersion,
...(actionCode === 'SYSTEM_UPDATE_CHECK' ? { releaseNotes: 'Preview update' } : {}), ...(actionCode === 'SYSTEM_UPDATE_CHECK'
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}), ? { releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
: {}),
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED'
? { state: 'SUCCEEDED', releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
: {}),
}), }),
occurredAt: '2026-08-16T00:02:00Z', occurredAt: '2026-08-16T00:02:00Z',
allowedActions: [], allowedActions: [],
@@ -214,6 +218,7 @@ async function installFixture(
const query = new URL(request.url()).searchParams; const query = new URL(request.url()).searchParams;
expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z'); expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z');
expect(query.get('objectType')).toBe('SYSTEM_UPDATE'); expect(query.get('objectType')).toBe('SYSTEM_UPDATE');
expect(query.get('size')).toBe('100');
return route.fulfill({ return route.fulfill({
json: { json: {
data: history, data: history,
@@ -418,15 +423,18 @@ test('successful installation starts the ten-second automatic refresh countdown'
const historyPanel = page.locator('.history-panel'); const historyPanel = page.locator('.history-panel');
const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]'); const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]');
await expect(timelineItem).toHaveCount(1); await expect(timelineItem).toHaveCount(1);
await expect(timelineItem).toContainText('更新完成'); await expect(timelineItem).toContainText('成功');
await expect(timelineItem).toContainText('新版本已通过健康检查'); await expect(timelineItem).toContainText('新版本已通过健康检查');
await expect(timelineItem).toContainText('Preview update'); await expect(timelineItem).toContainText('Preview update');
await timelineItem.getByRole('button', { name: '查看完整记录', exact: true }).click(); await timelineItem.getByRole('button', { name: '查看更新日志', exact: true }).click();
const historyDialog = page.getByRole('dialog', { name: '更新日志详情' }); const historyDialog = page.getByRole('dialog', { name: '更新日志详情' });
await expect(historyDialog).toContainText('获取版本'); await expect(historyDialog).toContainText('更新日志');
await expect(historyDialog).toContainText('下载更新包'); await expect(historyDialog).toContainText('Preview update');
await expect(historyDialog).toContainText('立即更新并重启'); await expect(historyDialog).toContainText('发布时间');
await expect(historyDialog).toContainText('更新完成'); await expect(historyDialog.locator('.history-detail__steps')).toHaveCount(0);
await expect(historyDialog).not.toContainText('获取版本');
await expect(historyDialog).not.toContainText('下载更新包');
await expect(historyDialog).not.toContainText('立即更新并重启');
}); });
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => { test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
@@ -111,9 +111,9 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>历史更新记录</h2> <h2>历史更新记录</h2>
<p>按版本汇总获取、下载、重启安装和最终执行结果。</p> <p>按版本展示签名 Release 提供的更新日志与最终结果。</p>
</div> </div>
<span>最近 {{ historyTimeline.length }} 个版本 · {{ historyRows.length }} 条记录</span> <span>已记录 {{ historyTimeline.length }} 个版本</span>
</div> </div>
<t-alert v-if="historyError" theme="error" :close-btn="false"> <t-alert v-if="historyError" theme="error" :close-btn="false">
@@ -146,37 +146,19 @@
<t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag> <t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag>
</div> </div>
<div class="timeline-card__summary"> <div class="timeline-card__notes">
<span class="timeline-card__label">执行流程</span> <span>更新日志</span>
<span>{{ item.operations.join(' · ') }}</span> <p>{{ item.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
<span class="timeline-card__count">{{ item.steps.length }} 条记录</span>
</div> </div>
<p class="timeline-card__reason">{{ item.reason || '已记录本次版本更新操作。' }}</p> <p class="timeline-card__reason">{{ item.reason || '更新结果已记录。' }}</p>
<div class="timeline-card__published">
<div v-if="item.releaseNotes" class="timeline-card__notes"> <span v-if="item.publishedAt">发布时间:{{ formatDateTime(item.publishedAt) }}</span>
<span>版本说明</span> <span>记录时间:{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
<p>{{ item.releaseNotes }}</p>
</div>
<div class="timeline-steps" aria-label="更新步骤">
<div v-for="step in item.steps" :key="step.row.publicId" class="timeline-step">
<span class="timeline-step__dot" :class="`timeline-step__dot--${resultTheme(step.row.resultCode)}`" />
<div class="timeline-step__body">
<div class="timeline-step__heading">
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
{{ resultLabel(step.row.resultCode) }}
</t-tag>
<time>{{ formatDateTime(step.row.occurredAt) }}</time>
</div>
<p>{{ step.row.reason || '操作已记录' }} · {{ step.row.username || '系统' }}</p>
</div>
</div>
</div> </div>
<div class="timeline-card__footer"> <div class="timeline-card__footer">
<span>最后更新:{{ formatDateTime(item.occurredAt) }}</span> <span>目标版本 {{ item.version }}</span>
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看完整记录</t-button> <t-button variant="text" size="small" @click="openHistoryDetail(item)">查看更新日志</t-button>
</div> </div>
</div> </div>
</article> </article>
@@ -283,23 +265,17 @@
{{ resultLabel(selectedHistory.resultCode) }} {{ resultLabel(selectedHistory.resultCode) }}
</t-tag> </t-tag>
</div> </div>
<p class="history-detail__reason">{{ selectedHistory.reason || '已记录本次版本更新操作。' }}</p> <div class="history-detail__metadata">
<div v-if="selectedHistory.releaseNotes" class="history-detail__notes"> <span v-if="selectedHistory.publishedAt">发布时间:{{ formatDateTime(selectedHistory.publishedAt) }}</span>
<span>版本说明</span> <span
<p>{{ selectedHistory.releaseNotes }}</p> >记录时间:{{ formatDateTime(selectedHistory.occurredAt) }} · {{ selectedHistory.username || '系统' }}</span
>
</div> </div>
<div class="history-detail__steps" aria-label="更新日志"> <div class="history-detail__notes">
<div v-for="step in selectedHistory.steps" :key="step.row.publicId" class="history-detail__step"> <span>更新日志</span>
<div> <p>{{ selectedHistory.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
{{ resultLabel(step.row.resultCode) }}
</t-tag>
</div>
<time>{{ formatDateTime(step.row.occurredAt) }} · {{ step.row.username || '系统' }}</time>
<p>{{ step.row.reason || '操作已记录' }}</p>
</div>
</div> </div>
<p class="history-detail__reason">{{ selectedHistory.reason || '更新结果已记录。' }}</p>
</div> </div>
</t-dialog> </t-dialog>
</div> </div>
@@ -322,10 +298,6 @@ import {
defineOptions({ name: 'SystemUpdatePage' }); defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
interface HistoryTimelineStep {
row: AuditLog;
}
interface HistoryTimelineItem { interface HistoryTimelineItem {
key: string; key: string;
version: string; version: string;
@@ -333,9 +305,8 @@ interface HistoryTimelineItem {
username: string; username: string;
resultCode: string; resultCode: string;
reason: string; reason: string;
operations: string[];
releaseNotes: string; releaseNotes: string;
steps: HistoryTimelineStep[]; publishedAt: string;
} }
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending'; const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
@@ -498,9 +469,8 @@ const historyTimeline = computed<HistoryTimelineItem[]>(() => {
const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt)); const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode)); const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode));
const representative = terminal || newestFirst[0]; const representative = terminal || newestFirst[0];
const chronological = newestFirst.slice().reverse();
const operations = [...new Set(chronological.map((row) => actionLabel(row.actionCode)).filter(Boolean))];
const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || ''; const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || '';
const publishedAt = newestFirst.map((row) => historyPublishedAt(row)).find(Boolean) || '';
return { return {
key, key,
@@ -509,9 +479,8 @@ const historyTimeline = computed<HistoryTimelineItem[]>(() => {
username: representative?.username || '', username: representative?.username || '',
resultCode: representative?.resultCode || 'UNKNOWN', resultCode: representative?.resultCode || 'UNKNOWN',
reason: representative?.reason || '', reason: representative?.reason || '',
operations,
releaseNotes, releaseNotes,
steps: chronological.map((row) => ({ row })), publishedAt,
}; };
}) })
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt)); .sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
@@ -571,7 +540,7 @@ async function loadHistory(silent = false) {
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc', sort: 'occurredAt,desc',
page: 1, page: 1,
size: 20, size: 100,
}); });
historyRows.value = result.items; historyRows.value = result.items;
} catch (error) { } catch (error) {
@@ -859,7 +828,24 @@ function historyVersion(row: AuditLog) {
function historyReleaseNotes(row: AuditLog) { function historyReleaseNotes(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) { for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source); const parsed = parseHistoryPayload(source);
if (typeof parsed?.releaseNotes === 'string' && parsed.releaseNotes.trim()) return parsed.releaseNotes; for (const key of ['releaseNotes', 'changelog', 'updateLog', 'notes']) {
const value = parsed?.[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (Array.isArray(value)) {
const lines = value
.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
.map((item) => item.trim());
if (lines.length) return lines.join('\n');
}
}
}
return '';
}
function historyPublishedAt(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
const parsed = parseHistoryPayload(source);
if (typeof parsed?.publishedAt === 'string' && parsed.publishedAt.trim()) return parsed.publishedAt;
} }
return ''; return '';
} }
@@ -869,19 +855,6 @@ function openHistoryDetail(item: HistoryTimelineItem) {
historyDetailVisible.value = true; historyDetailVisible.value = true;
} }
function actionLabel(action: string) {
return (
{
SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
SYSTEM_UPDATE_SUCCEEDED: '更新完成',
SYSTEM_UPDATE_FAILED: '更新失败',
SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复',
}[action] || action
);
}
function resultLabel(result: string) { function resultLabel(result: string) {
return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result; return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result;
} }
@@ -1155,9 +1128,7 @@ onBeforeUnmount(() => {
} }
.timeline-card__header, .timeline-card__header,
.timeline-card__summary,
.timeline-card__footer, .timeline-card__footer,
.timeline-step__heading,
.history-detail__header { .history-detail__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1188,35 +1159,13 @@ onBeforeUnmount(() => {
} }
.timeline-card__identity span, .timeline-card__identity span,
.timeline-card__count,
.timeline-card__footer, .timeline-card__footer,
.timeline-step__heading time,
.history-detail__header span, .history-detail__header span,
.history-detail__step time { .history-detail__metadata {
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: 12px; font-size: 12px;
} }
.timeline-card__summary {
flex-wrap: wrap;
margin-top: 12px;
padding: 9px 10px;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 20px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 4px;
}
.timeline-card__label {
color: var(--td-text-color-secondary);
}
.timeline-card__count {
margin-left: auto;
white-space: nowrap;
}
.timeline-card__reason, .timeline-card__reason,
.history-detail__reason { .history-detail__reason {
margin: 12px 0 0; margin: 12px 0 0;
@@ -1250,65 +1199,15 @@ onBeforeUnmount(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.timeline-steps { .timeline-card__published,
.history-detail__metadata {
display: flex; display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.timeline-step {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
min-width: 0;
}
.timeline-step__dot {
width: 8px;
height: 8px;
margin-top: 7px;
background: var(--td-component-border);
border-radius: 50%;
}
.timeline-step__dot--success {
background: var(--td-success-color);
}
.timeline-step__dot--danger {
background: var(--td-error-color);
}
.timeline-step__dot--warning {
background: var(--td-warning-color);
}
.timeline-step__body {
min-width: 0;
}
.timeline-step__heading {
flex-wrap: wrap; flex-wrap: wrap;
} gap: 6px 16px;
margin-top: 10px;
.timeline-step__heading strong {
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.timeline-step__heading time {
margin-left: auto;
white-space: nowrap;
}
.timeline-step__body p {
margin: 3px 0 0;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
overflow-wrap: anywhere;
} }
.timeline-card__footer { .timeline-card__footer {
@@ -1336,53 +1235,6 @@ onBeforeUnmount(() => {
line-height: 26px; line-height: 26px;
} }
.history-detail__steps {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 420px;
padding: 12px;
overflow: auto;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.history-detail__step {
padding-bottom: 12px;
border-bottom: 1px solid var(--td-component-border);
}
.history-detail__step:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.history-detail__step > div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.history-detail__step strong {
color: var(--td-text-color-primary);
font-size: 13px;
}
.history-detail__step time {
display: block;
margin-top: 4px;
}
.history-detail__step p {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 20px;
overflow-wrap: anywhere;
}
.alert-content { .alert-content {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@@ -1552,16 +1404,6 @@ onBeforeUnmount(() => {
padding: 12px; padding: 12px;
} }
.timeline-card__count {
width: 100%;
margin-left: 0;
}
.timeline-step__heading time {
width: 100%;
margin-left: 0;
}
.timeline-card__notes, .timeline-card__notes,
.history-detail__notes { .history-detail__notes {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+11
View File
@@ -0,0 +1,11 @@
更新日志(Preview 47)
本版本聚焦在线更新的可追溯性与发布说明展示,不改变财务业务数据和审批规则。
• 更新历史改为按版本展示 Release 更新日志,不再把获取、下载、重启等内部执行步骤当作历史内容。
• 历史详情直接展示签名 Release 的发布说明、发布时间、目标版本和最终结果。
• 更新器在验签后把 releaseNotes 与 publishedAt 写入状态文件;应用重启后仍能显示完整发布说明。
• 终态审计记录保存发布说明并保持幂等,历史记录不再只显示“更新成功”一条信息。
• 发布打包、验签和 Gitea Release 正文统一读取 release-notes/<version>.md,避免页面、清单和 Release 描述不一致。
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
+14 -1
View File
@@ -187,7 +187,20 @@ KAIDI_PURGER_SHA256=$PURGER_SHA256
EOF EOF
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt") BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"} RELEASE_NOTES_FILE=${KAIDI_RELEASE_NOTES_FILE:-$ROOT/release-notes/$VERSION.md}
if [ -n "${KAIDI_RELEASE_NOTES+x}" ]; then
RELEASE_NOTES_VALUE=$KAIDI_RELEASE_NOTES
elif [ -f "$RELEASE_NOTES_FILE" ] && [ ! -L "$RELEASE_NOTES_FILE" ]; then
RELEASE_NOTES_VALUE=$(sed 's/\r$//' "$RELEASE_NOTES_FILE")
elif [ "$SOURCE_REF" != local ] || [ "${KAIDI_REQUIRE_RELEASE_NOTES:-false}" = true ]; then
printf 'Release notes file is missing: %s\n' "$RELEASE_NOTES_FILE" >&2
exit 1
else
RELEASE_NOTES_VALUE="Kaidi Finance Preview $VERSION"
fi
RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES_VALUE" | wc -c | tr -d '[:space:]')
[ "$RELEASE_NOTES_BYTES" -gt 0 ] && [ "$RELEASE_NOTES_BYTES" -le 4000 ] \
|| { printf 'Release notes must contain 1 to 4000 bytes\n' >&2; exit 1; }
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \ VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \
RELEASE_NOTES="$RELEASE_NOTES_VALUE" \ RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \ BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
+10 -3
View File
@@ -10,9 +10,7 @@ SOURCE_SHA=${GITEA_SHA:-}
TOKEN=${GITEA_TOKEN:-} TOKEN=${GITEA_TOKEN:-}
RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release} RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release}
RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG} RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG}
RELEASE_BODY=${KAIDI_RELEASE_BODY:-Kaidi Finance $TAG RELEASE_BODY=${KAIDI_RELEASE_BODY-}
Source: $SOURCE_SHA}
WORK=$(mktemp -d) WORK=$(mktemp -d)
AUTH_HEADER=$WORK/gitea-auth-header AUTH_HEADER=$WORK/gitea-auth-header
@@ -38,6 +36,15 @@ esac
|| fail 'GITEA_TOKEN is invalid' || fail 'GITEA_TOKEN is invalid'
[ -d "$RELEASE_DIR" ] || fail 'release directory is missing' [ -d "$RELEASE_DIR" ] || fail 'release directory is missing'
if [ -z "${KAIDI_RELEASE_BODY+x}" ]; then
RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \
"$RELEASE_DIR/release-manifest.json") \
|| fail 'release manifest notes are missing or invalid'
RELEASE_BODY="$RELEASE_NOTES
Source: $SOURCE_SHA"
fi
printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER" printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER"
chmod 0600 "$AUTH_HEADER" chmod 0600 "$AUTH_HEADER"
+1
View File
@@ -29,6 +29,7 @@ for name in \
SHA256SUMS; do SHA256SUMS; do
printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name" printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name"
done done
printf '%s\n' '{"releaseNotes":"Fixture release notes"}' > "$RELEASE_DIR/release-manifest.json"
cat > "$MOCK_BIN/curl" <<'SH' cat > "$MOCK_BIN/curl" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
+6
View File
@@ -315,6 +315,10 @@ download_and_prepare_install() {
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \ [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \ && [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \
|| fail 'download phase did not preserve request correlation' || fail 'download phase did not preserve request correlation'
[ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \
|| fail 'download phase did not persist the signed release notes'
[ "$(jq -r '.publishedAt' "$fixture/state/status.json")" = 2026-08-16T00:00:00Z ] \
|| fail 'download phase did not persist the signed release publish time'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'download phase changed the active application' || fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \ [ -s "$fixture/state/cache/$version/release.tar.gz" ] \
@@ -456,6 +460,8 @@ assert_success_case() {
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \ [ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \ && [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|| fail 'success case did not preserve install request correlation' || fail 'success case did not preserve install request correlation'
[ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \
|| fail 'success case did not retain the signed release notes'
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind' || fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
+5
View File
@@ -30,6 +30,11 @@ openssl dgst -sha256 -verify release-public.pem \
VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json) VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json)
"$ROOT/scripts/check-semver.sh" "$VERSION" \ "$ROOT/scripts/check-semver.sh" "$VERSION" \
|| { printf 'Release version is not valid SemVer\n' >&2; exit 1; } || { printf 'Release version is not valid SemVer\n' >&2; exit 1; }
RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' release-manifest.json) \
|| { printf 'Release manifest must contain 1 to 4000 bytes of release notes\n' >&2; exit 1; }
RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES" | wc -c | tr -d '[:space:]')
[ "$RELEASE_NOTES_BYTES" -le 4000 ] \
|| { printf 'Release manifest release notes exceed 4000 bytes\n' >&2; exit 1; }
ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json) ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json)
[ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; } [ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; }
[ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \ [ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \