From 1d45be4e97b3cca8c1213126ab465ae93468b9a9 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Wed, 19 Aug 2026 09:04:33 +0800 Subject: [PATCH] fix: synchronize release notes after update restart --- README.md | 27 ++++--- .../SystemUpdateApplicationService.java | 75 +++++++++++++++++-- .../SystemUpdateApplicationServiceTest.java | 38 +++++++++- deploy/env.production.example | 2 +- release-notes/1.0.0-preview.48.md | 11 +++ scripts/package-release.sh | 43 +++++++---- scripts/verify-release.sh | 21 ++++++ 7 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 release-notes/1.0.0-preview.48.md diff --git a/README.md b/README.md index bed062c..c322c59 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ PostgreSQL 18 兼容工作继续冻结。 - MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。 - 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。 - `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。 -- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。 +- 发布归档使用无顶层目录的 `tar.gz`,包含 `app.jar`、`public/`、`ops/`、`VERSION` 和签名绑定的 + `release-metadata.json`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。 ### 32 位 Linux 支持边界 @@ -83,7 +84,7 @@ PostgreSQL 18 兼容工作继续冻结。 先在宝塔面板停止并删除当前错误的 Java 项目,再执行: ```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 +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/install.sh | sudo env KAIDI_APP_PORT=18080 bash ``` 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 @@ -96,7 +97,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 随后执行一键清理: ```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 +curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash ``` 上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。 @@ -117,9 +118,9 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员 下载地址: -`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.48/kaidi-finance-1.0.0-preview.48.tar.gz` -校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.47/SHA256SUMS` +校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/SHA256SUMS` 服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要 systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 @@ -136,7 +137,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 ```bash -VERSION=1.0.0-preview.47 +VERSION=1.0.0-preview.48 APP_ROOT=/www/wwwroot/kaidi RELEASE_ROOT="$APP_ROOT/releases/$VERSION" sudo install -d -m 0755 "$RELEASE_ROOT" @@ -245,7 +246,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线 `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 +curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash ``` 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, @@ -269,12 +270,14 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标 工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-notes/.md` 写好面向用户的更新日志。 -打包器会把同一份内容写入签名 `release-manifest.json`,后台历史从该清单读取;发布脚本也会用它生成 -Gitea Release 正文,避免三处内容不一致。之后推送 tag 即会构建、测试、签名并发布: +打包器会把同一份内容写入签名 `release-manifest.json`,并把同一份签名元数据随应用制品写入 +`release-metadata.json`。后台在线检查优先读取 Release 清单;应用重启后若旧版更新器没有把说明写入状态文件, +则从当前已验签制品中的元数据恢复,再写入终态审计,因此不依赖重启后的 Gitea 网络请求。发布脚本也会用 +清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.47 -git push origin v1.0.0-preview.47 +git tag v1.0.0-preview.48 +git push origin v1.0.0-preview.48 ``` 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 @@ -326,7 +329,7 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.47 + ./scripts/package-release.sh 1.0.0-preview.48 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` diff --git a/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java index 9c782ff..63dac13 100644 --- a/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java +++ b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java @@ -34,6 +34,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.slf4j.Logger; @@ -47,6 +48,7 @@ public class SystemUpdateApplicationService { private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class); private static final int MAX_MANIFEST_BYTES = 64 * 1024; + private static final int MAX_RELEASE_METADATA_BYTES = 16 * 1024; private static final int MAX_RELEASE_API_BYTES = 256 * 1024; private static final int MAX_STATUS_BYTES = 64 * 1024; private static final int MAX_EVENT_LOG_BYTES = 256 * 1024; @@ -418,15 +420,71 @@ public class SystemUpdateApplicationService { } private UpdateStatus readStatus() { + UpdateStatus resolved; if (!effectiveEnabled()) { - return new UpdateStatus("DISABLED", "在线更新未配置", null, null); + resolved = new UpdateStatus("DISABLED", "在线更新未配置", null, null); + } else { + UpdateStatus persisted = readPersistedStatus(); + if (persisted != null && BUSY_STATES.contains(persisted.state())) { + resolved = persisted; + } else if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) { + resolved = persisted; + } else { + UpdateStatus queued = pendingStatus(); + resolved = queued != null + ? queued + : (persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted); + } + } + return enrichWithEmbeddedReleaseMetadata(resolved); + } + + /** + * The updater which performed an older release switch may not have known about release notes yet. + * New artifacts therefore carry the signed notes inside the release directory as well. Reading that + * immutable, artifact-hashed file lets the first application process after an upgrade reconstruct the + * terminal audit snapshot without a network request or a database migration. + */ + private UpdateStatus enrichWithEmbeddedReleaseMetadata(UpdateStatus status) { + if (status == null) return null; + ReleaseMetadata metadata = readEmbeddedReleaseMetadata(); + if (metadata == null) return status; + String current = currentVersion(); + boolean matchesCurrent = metadata.version().equals(current); + boolean matchesTarget = status.targetVersion() != null && metadata.version().equals(status.targetVersion()); + if (!matchesCurrent && !matchesTarget) return status; + String notes = status.releaseNotes() == null || status.releaseNotes().isBlank() + ? metadata.releaseNotes() : status.releaseNotes(); + Instant publishedAt = status.publishedAt() == null ? metadata.publishedAt() : status.publishedAt(); + if (notes.equals(status.releaseNotes()) && Objects.equals(publishedAt, status.publishedAt())) return status; + return new UpdateStatus(status.state(), status.message(), status.targetVersion(), status.updatedAt(), + status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(), + status.restartExpectedSeconds(), status.requestId(), status.action(), notes, publishedAt); + } + + private ReleaseMetadata readEmbeddedReleaseMetadata() { + Path versionFile = properties.currentVersionFile(); + if (versionFile == null) return null; + Path releaseDirectory = versionFile.toAbsolutePath().normalize().getParent(); + if (releaseDirectory == null) return null; + Path metadataFile = releaseDirectory.resolve("release-metadata.json").normalize(); + if (!metadataFile.startsWith(releaseDirectory) + || !Files.isRegularFile(metadataFile, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(metadataFile)) { + return null; + } + try { + if (Files.size(metadataFile) > MAX_RELEASE_METADATA_BYTES) return null; + JsonNode root = objectMapper.readTree(Files.readAllBytes(metadataFile)); + String version = blank(root.path("version").asText(null)); + String notes = boundedText(root, "releaseNotes", 4000); + Instant publishedAt = parseInstant(root.path("publishedAt").asText(null)); + if (version == null || !VERSION.matcher(version).matches() || notes == null) return null; + return new ReleaseMetadata(version, notes, publishedAt); + } catch (IOException | RuntimeException exception) { + log.debug("嵌入式 Release 更新日志读取失败:{}", metadataFile, exception); + return null; } - UpdateStatus persisted = readPersistedStatus(); - if (persisted != null && BUSY_STATES.contains(persisted.state())) return persisted; - if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted; - UpdateStatus queued = pendingStatus(); - if (queued != null) return queued; - return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted; } private UpdateStatus readPersistedStatus() { @@ -702,6 +760,9 @@ public class SystemUpdateApplicationService { String releaseNotes) { } + private record ReleaseMetadata(String version, String releaseNotes, Instant publishedAt) { + } + private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, Long downloadedBytes, Long totalBytes, Long bytesPerSecond, Integer downloadPercent, Integer restartExpectedSeconds, diff --git a/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java b/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java index 6149011..7548c4d 100644 --- a/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java +++ b/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java @@ -336,8 +336,44 @@ class SystemUpdateApplicationServiceTest { "Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"), null, null); } + @Test + void restoresEmbeddedReleaseNotesAfterLegacyUpdaterSwitch() throws Exception { + Path inbox = Files.createDirectory(tempDir.resolve("embedded-notes-inbox")); + Path versionFile = tempDir.resolve("current/VERSION"); + Files.createDirectories(versionFile.getParent()); + Files.writeString(versionFile, "1.0.0-preview.47\n"); + Files.writeString(versionFile.resolveSibling("release-metadata.json"), """ + {"version":"1.0.0-preview.47","publishedAt":"2026-08-19T00:28:41.701Z", + "releaseNotes":"从已签名制品恢复的更新日志"} + """); + Path statusFile = tempDir.resolve("embedded-notes-status.json"); + Files.writeString(statusFile, """ + {"state":"SUCCEEDED","message":"Release 1.0.0-preview.47 is running", + "targetVersion":"1.0.0-preview.47","updatedAt":"2026-08-19T00:30:00Z"} + """); + AuditService auditService = mock(AuditService.class); + when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47", + "Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"), + "从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z"))) + .thenReturn("SYSUPD:embedded-notes"); + SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService, versionFile); + + var status = service.status(); + + assertEquals("从已签名制品恢复的更新日志", status.releaseNotes()); + assertEquals(java.time.Instant.parse("2026-08-19T00:28:41.701Z"), status.publishedAt()); + verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47", + "Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"), + "从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z")); + } + private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) { - SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null, + return serviceForStatus(inbox, statusFile, auditService, null); + } + + private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService, + Path versionFile) { + SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", versionFile, "https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile, Duration.ofSeconds(2), Duration.ofSeconds(2), true); AuthorizationService authorization = mock(AuthorizationService.class); diff --git a/deploy/env.production.example b/deploy/env.production.example index ae842c9..1b1663f 100644 --- a/deploy/env.production.example +++ b/deploy/env.production.example @@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_PASSWORD= -APP_VERSION=1.0.0-preview.46 +APP_VERSION=1.0.0-preview.48 UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION FINANCE_UPDATE_ENABLED=true # Use either a stable direct asset base URL or the public Gitea latest-release API. diff --git a/release-notes/1.0.0-preview.48.md b/release-notes/1.0.0-preview.48.md new file mode 100644 index 0000000..0d840d5 --- /dev/null +++ b/release-notes/1.0.0-preview.48.md @@ -0,0 +1,11 @@ +更新日志(Preview 48) + +本版本修复在线更新完成后后台看不到 Release 更新日志的问题。 + +• 更新日志继续以签名 release-manifest.json 为唯一发布来源,Gitea Release 正文与清单使用同一份内容。 +• 发布制品新增 release-metadata.json,并由签名制品携带版本、发布时间和完整更新说明。 +• 应用重启后会从当前已验签制品恢复更新说明;即使由旧版更新器完成版本切换,也不会再丢失日志。 +• 更新完成后的终态审计会保存 Release 更新说明,系统更新页按版本显示完整日志,而不是只显示“更新成功”。 +• 增加发布清单与制品内元数据的一致性校验,避免版本、发布时间和更新说明发生漂移。 + +升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 59ee559..bae1754 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -94,6 +94,22 @@ if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \ exit 1 fi +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; } +PUBLISHED_AT=$(node -e 'process.stdout.write(new Date().toISOString())') + rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" STAGE="$WORK/stage" @@ -112,6 +128,15 @@ cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.servic cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service" cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path" chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh" +VERSION="$VERSION" RELEASE_NOTES="$RELEASE_NOTES_VALUE" PUBLISHED_AT="$PUBLISHED_AT" \ + node <<'NODE' > "$STAGE/release-metadata.json" +const metadata = { + version: process.env.VERSION, + publishedAt: process.env.PUBLISHED_AT, + releaseNotes: process.env.RELEASE_NOTES, +}; +process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`); +NODE if find "$STAGE" -type l -print -quit | grep -q .; then printf 'Release stage contains a symbolic link\n' >&2 @@ -187,22 +212,9 @@ KAIDI_PURGER_SHA256=$PURGER_SHA256 EOF BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt") -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" \ RELEASE_NOTES="$RELEASE_NOTES_VALUE" \ + PUBLISHED_AT="$PUBLISHED_AT" \ BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \ BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \ INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \ @@ -215,7 +227,8 @@ const manifest = { artifact: process.env.ARTIFACT, sha256: process.env.SHA256, artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES), - publishedAt: new Date().toISOString(), + publishedAt: process.env.PUBLISHED_AT, + releaseMetadata: 'release-metadata.json', minimumJava: 17, source: { revision: process.env.SOURCE_REVISION, diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 2e466f0..bd32b6f 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -166,6 +166,27 @@ if tar -tvzf "$ARTIFACT" | grep -Eq '^l'; then printf 'Release archive contains a symbolic link\n' >&2 exit 1 fi +RELEASE_METADATA_PATH=$(jq -r '.releaseMetadata // empty' release-manifest.json) +if [ -n "$RELEASE_METADATA_PATH" ]; then + [ "$RELEASE_METADATA_PATH" = "release-metadata.json" ] \ + || { printf 'Release metadata path is invalid\n' >&2; exit 1; } + tar -xOf "$ARTIFACT" "./$RELEASE_METADATA_PATH" > "$WORK/release-metadata.json" \ + || { printf 'Release archive is missing embedded release metadata\n' >&2; exit 1; } + METADATA_VERSION=$(jq -er '.version | strings | select(length > 0)' "$WORK/release-metadata.json") \ + || { printf 'Embedded release metadata version is invalid\n' >&2; exit 1; } + METADATA_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \ + "$WORK/release-metadata.json") \ + || { printf 'Embedded release metadata notes are invalid\n' >&2; exit 1; } + METADATA_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' \ + "$WORK/release-metadata.json") \ + || { printf 'Embedded release metadata publish time is invalid\n' >&2; exit 1; } + MANIFEST_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' release-manifest.json) \ + || { printf 'Release manifest publish time is invalid\n' >&2; exit 1; } + [ "$METADATA_VERSION" = "$VERSION" ] \ + && [ "$METADATA_NOTES" = "$RELEASE_NOTES" ] \ + && [ "$METADATA_PUBLISHED_AT" = "$MANIFEST_PUBLISHED_AT" ] \ + || { printf 'Embedded release metadata does not match the signed manifest\n' >&2; exit 1; } +fi [ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \ || { printf 'Release archive version does not match the manifest\n' >&2; exit 1; } tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"