fix: synchronize release notes after update restart
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 09:04:33 +08:00
parent 6b04d8dd0a
commit 1d45be4e97
7 changed files with 181 additions and 36 deletions
+15 -12
View File
@@ -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/<version>.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
```
@@ -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())) return persisted;
if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted;
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();
if (queued != null) return queued;
return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted;
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;
}
}
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,
@@ -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);
+1 -1
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
更新日志(Preview 48)
本版本修复在线更新完成后后台看不到 Release 更新日志的问题。
• 更新日志继续以签名 release-manifest.json 为唯一发布来源,Gitea Release 正文与清单使用同一份内容。
• 发布制品新增 release-metadata.json,并由签名制品携带版本、发布时间和完整更新说明。
• 应用重启后会从当前已验签制品恢复更新说明;即使由旧版更新器完成版本切换,也不会再丢失日志。
• 更新完成后的终态审计会保存 Release 更新说明,系统更新页按版本显示完整日志,而不是只显示“更新成功”。
• 增加发布清单与制品内元数据的一致性校验,避免版本、发布时间和更新说明发生漂移。
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
+28 -15
View File
@@ -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,
+21
View File
@@ -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"