release: simplify unsigned installation
This commit is contained in:
+3
-3
@@ -27,9 +27,9 @@ TALLYNOTE_INSTALL_PREFIX=./
|
|||||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||||
TALLYNOTE_UPDATE_MAX_MB=512
|
TALLYNOTE_UPDATE_MAX_MB=512
|
||||||
# One-click/systemd updates require an Ed25519 signature over SHA256SUMS.
|
# SHA-256 is always required. Detached Ed25519 signatures are optional; set
|
||||||
# Keep this file root-readable and point to a root-managed public key.
|
# this to true only when a root-managed public key is configured below.
|
||||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||||
|
|||||||
@@ -32,10 +32,9 @@ jobs:
|
|||||||
pnpm test
|
pnpm test
|
||||||
- name: Build Linux release
|
- name: Build Linux release
|
||||||
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
|
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
|
||||||
- name: Create and publish signed Gitea Release
|
- name: Create and publish Gitea Release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
TALLYNOTE_RELEASE_SIGNING_KEY: ${{ secrets.TALLYNOTE_RELEASE_SIGNING_KEY }}
|
|
||||||
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
|
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
|
||||||
|
|
||||||
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
|
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
|
||||||
|
|||||||
@@ -51,17 +51,15 @@ pnpm build:next
|
|||||||
|
|
||||||
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
|
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
|
||||||
|
|
||||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS` 和 `SHA256SUMS.sig`。安装器默认 dry-run,只有显式 `--apply` 才会下载或写盘;正式安装必须提供独立核对过的 Ed25519 公钥:
|
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --proto '=https' --tlsv1.2 -fsSL \
|
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||||
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
|
|
||||||
| sudo bash -s -- --apply --version 1.1.0 \
|
|
||||||
--signing-key /root/tallynote-update.pub \
|
|
||||||
--update-public-key-file /root/tallynote-update.pub
|
|
||||||
```
|
```
|
||||||
|
|
||||||
指定版本时,脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 获取归档、`SHA256SUMS` 和签名。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。`--allow-unsigned` 仅供隔离开发机测试,不能用于公网或真实财务数据。
|
脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。
|
||||||
|
|
||||||
|
如需额外启用签名校验,在环境中设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;不设置时不会要求公钥或 `SHA256SUMS.sig`。
|
||||||
|
|
||||||
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
|
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
|
||||||
|
|
||||||
@@ -69,7 +67,7 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
|||||||
|
|
||||||
升级有两种方式:
|
升级有两种方式:
|
||||||
|
|
||||||
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单、SHA-256 和 Ed25519 签名校验的请求写入队列;root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源、验证签名,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||||
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
|
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
|
||||||
|
|
||||||
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
|
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
|
||||||
@@ -82,17 +80,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm release:build 1.1.0 ./release
|
pnpm release:build 1.1.1 ./release
|
||||||
```
|
```
|
||||||
|
|
||||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS` 与 `SHA256SUMS.sig`;当前仓库还没有首个 tag/release 时,后台会明确显示不可用,不会下载未验证文件。CI 需要 `GITEA_TOKEN` 和 `TALLYNOTE_RELEASE_SIGNING_KEY` secrets。
|
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS`;如果 CI 提供签名私钥,还会额外上传 `SHA256SUMS.sig`。CI 只需要 `GITEA_TOKEN`;签名私钥属于可选增强。
|
||||||
|
|
||||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.0` 与 `v1.1.0`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.1` 与 `v1.1.1`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "release: 1.1.0"
|
git commit -m "release: 1.1.1"
|
||||||
git tag -a v1.1.0 -m "TallyNote 1.1.0"
|
git tag -a v1.1.1 -m "TallyNote 1.1.1"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -109,4 +107,4 @@ docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js -
|
|||||||
|
|
||||||
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
|
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
|
||||||
|
|
||||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256/签名的归档、路径穿越、特殊文件和符号链接;附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||||
|
|||||||
+14
-17
@@ -6,48 +6,45 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
|
|||||||
|
|
||||||
## 自动发布
|
## 自动发布
|
||||||
|
|
||||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.0`)会触发 `.gitea/workflows/release.yml`:
|
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.1`)会触发 `.gitea/workflows/release.yml`:
|
||||||
|
|
||||||
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
|
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
|
||||||
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
|
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
|
||||||
3. 用 Ed25519 私钥生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和签名。
|
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
|
||||||
|
|
||||||
在仓库的 Actions secrets 配置:
|
在仓库的 Actions secrets 配置:
|
||||||
|
|
||||||
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
|
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
|
||||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
- `TALLYNOTE_RELEASE_SIGNING_KEY`:可选的 Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||||
|
|
||||||
也可以在 Linux 发布机上手动执行:
|
也可以在 Linux 发布机上手动执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm check && pnpm test
|
pnpm check && pnpm test
|
||||||
pnpm release:build 1.1.0 ./release
|
pnpm release:build 1.1.1 ./release
|
||||||
GITHUB_REPOSITORY=awaioi/TallyNote \
|
GITHUB_REPOSITORY=awaioi/TallyNote \
|
||||||
GITEA_TOKEN=... \
|
GITEA_TOKEN=... \
|
||||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \
|
./scripts/publish-gitea-release.sh v1.1.1 ./release
|
||||||
./scripts/publish-gitea-release.sh v1.1.0 ./release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
发布资产名称必须包含当前平台,例如 `tallynote-1.1.0-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
|
发布资产名称必须包含当前平台,例如 `tallynote-1.1.1-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
|
||||||
|
|
||||||
## curl 安装
|
## curl 安装
|
||||||
|
|
||||||
安装器默认只做 dry-run;只有显式 `--apply` 才会下载或写盘。正式安装必须同时提供 Ed25519 公钥和 `SHA256SUMS.sig`,公钥应通过独立的受信渠道核对指纹。下面示例假设公钥已安全放在服务器 `/root/tallynote-update.pub`:
|
安装器默认直接获取并安装 latest Release。它始终校验 `SHA256SUMS` 中的 SHA-256,不要求公钥或签名文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --proto '=https' --tlsv1.2 -fsSL \
|
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||||
https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \
|
|
||||||
| sudo bash -s -- --apply --version 1.1.0 \
|
|
||||||
--signing-key /root/tallynote-update.pub \
|
|
||||||
--update-public-key-file /root/tallynote-update.pub
|
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档、`SHA256SUMS` 和 `SHA256SUMS.sig`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。
|
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
|
||||||
|
|
||||||
|
如需启用签名校验,设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;后台更新同样可通过 `TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 开启。默认关闭签名要求,方便公开自维护仓库直接更新。
|
||||||
|
|
||||||
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
|
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
|
||||||
|
|
||||||
`--allow-unsigned` 只用于隔离的开发/测试主机,不能用于公网或保存真实财务数据的服务器。安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。
|
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。`--allow-unsigned` 作为旧版本兼容参数保留。
|
||||||
|
|
||||||
安装布局:
|
安装布局:
|
||||||
|
|
||||||
@@ -63,9 +60,9 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
|||||||
|
|
||||||
## 后台一键更新
|
## 后台一键更新
|
||||||
|
|
||||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL`、`TALLYNOTE_UPDATE_ALLOWED_HOSTS` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。
|
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
|
||||||
|
|
||||||
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata、清单和签名,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata 和清单,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo /usr/local/sbin/tallynote-update --rollback
|
sudo /usr/local/sbin/tallynote-update --rollback
|
||||||
|
|||||||
+32
-27
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
# TallyNote native installer. Dry-run by default; pass --apply to mutate the host.
|
# TallyNote native installer. Installs the latest release by default; use
|
||||||
|
# --dry-run to preview without changing the host.
|
||||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||||
export PATH
|
export PATH
|
||||||
umask 077
|
umask 077
|
||||||
@@ -20,9 +21,9 @@ SIGNING_KEY=${TALLYNOTE_SIGNING_KEY:-}
|
|||||||
SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519}
|
SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519}
|
||||||
SHA256_FILE=${TALLYNOTE_SHA256_FILE:-}
|
SHA256_FILE=${TALLYNOTE_SHA256_FILE:-}
|
||||||
UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}
|
UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}
|
||||||
APPLY=0
|
APPLY=1
|
||||||
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
|
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
|
||||||
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-true}
|
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-false}
|
||||||
ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false}
|
ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false}
|
||||||
ALLOW_UNSIGNED=0
|
ALLOW_UNSIGNED=0
|
||||||
MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512}
|
MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512}
|
||||||
@@ -51,17 +52,19 @@ RELEASE_API_URL=${RELEASE_API_URL%/}
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage: install.sh [--apply] [--version VERSION] [--release-base-url HTTPS_URL]
|
Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||||
[--release-file FILE] [--sha256-url HTTPS_URL|--sha256-file FILE]
|
[--release-file FILE] [--sha256-url HTTPS_URL|--sha256-file FILE]
|
||||||
[--signature-url HTTPS_URL] [--signing-key PUBLIC_KEY_FILE]
|
[--signature-url HTTPS_URL] [--signing-key PUBLIC_KEY_FILE]
|
||||||
[--signature-format ed25519|gpg]
|
[--signature-format ed25519|gpg]
|
||||||
[--update-public-key-file FILE]
|
[--update-public-key-file FILE]
|
||||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--dry-run]
|
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply]
|
||||||
|
|
||||||
The default is --dry-run. Network downloads and filesystem changes happen only
|
Without arguments, the installer resolves the latest compatible release and
|
||||||
with --apply. Production installs require a detached signature (Ed25519 over
|
installs it. SHA-256 from SHA256SUMS is always required. Detached signature
|
||||||
SHA256SUMS by default; legacy GPG archive signatures are opt-in); --allow-unsigned
|
verification is optional by default; enable it with
|
||||||
is for isolated development hosts only.
|
TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
|
||||||
|
--dry-run to inspect the selected release without downloading or changing the
|
||||||
|
host. --apply is accepted for backwards compatibility.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
||||||
@@ -275,9 +278,9 @@ verify_archive() {
|
|||||||
[[ -n "$expected" ]] || die "checksum file has no entry for $archive_name"
|
[[ -n "$expected" ]] || die "checksum file has no entry for $archive_name"
|
||||||
[[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'checksum file does not contain a SHA-256 digest'
|
[[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'checksum file does not contain a SHA-256 digest'
|
||||||
printf '%s %s\n' "$expected" "$archive" | sha256sum -c - >/dev/null || die 'SHA-256 verification failed'
|
printf '%s %s\n' "$expected" "$archive" | sha256sum -c - >/dev/null || die 'SHA-256 verification failed'
|
||||||
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
|
if [[ "$REQUIRE_SIGNATURE" == true || ( -n "$signature" && -n "$key" ) ]]; then
|
||||||
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少 SHA256SUMS.sig;生产安装必须使用签名'
|
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少签名文件(SHA256SUMS.sig 或 .asc)'
|
||||||
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '生产安装必须提供签名公钥(--signing-key FILE)'
|
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '签名校验需要有效的公钥文件(--signing-key FILE)'
|
||||||
[[ "$(stat_uid "$key")" == 0 ]] || die '更新公钥必须由 root 拥有'
|
[[ "$(stat_uid "$key")" == 0 ]] || die '更新公钥必须由 root 拥有'
|
||||||
[[ "$(wc -c < "$key" | tr -d '[:space:]')" -le 16384 ]] || die '更新公钥文件过大'
|
[[ "$(wc -c < "$key" | tr -d '[:space:]')" -le 16384 ]] || die '更新公钥文件过大'
|
||||||
local key_bits
|
local key_bits
|
||||||
@@ -314,7 +317,7 @@ verify_archive() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
elif [[ -n "$signature" || -n "$key" ]]; then
|
elif [[ -n "$signature" || -n "$key" ]]; then
|
||||||
log 'warning: signature verification disabled by explicit --allow-unsigned'
|
log 'warning: signature verification skipped; provide both a signature and public key, or enable TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +639,7 @@ validate_existing_env() {
|
|||||||
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
|
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
|
||||||
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
|
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
|
||||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
|
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
|
||||||
[[ -z "$value" || "$value" == true ]] || die '环境文件禁止关闭发布签名校验'
|
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
|
||||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
|
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
|
||||||
if [[ -n "$value" ]]; then
|
if [[ -n "$value" ]]; then
|
||||||
validate_env_value "$value" '环境文件更新源'
|
validate_env_value "$value" '环境文件更新源'
|
||||||
@@ -710,7 +713,7 @@ main() {
|
|||||||
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
|
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
|
||||||
validate_trusted_tool "$UNAME_BIN" 'uname'
|
validate_trusted_tool "$UNAME_BIN" 'uname'
|
||||||
fi
|
fi
|
||||||
if (( APPLY )) || [[ -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
|
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" || -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
|
||||||
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
||||||
fi
|
fi
|
||||||
detect_platform
|
detect_platform
|
||||||
@@ -730,7 +733,7 @@ main() {
|
|||||||
if [[ "$VERSION" == "latest" ]]; then
|
if [[ "$VERSION" == "latest" ]]; then
|
||||||
if (( ! APPLY )); then
|
if (( ! APPLY )); then
|
||||||
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
|
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
|
||||||
log 'version: latest (release lookup happens with --apply)'
|
log 'version: latest (release lookup skipped in dry-run)'
|
||||||
log 'dry-run: pass --version VERSION to preview an exact artifact'
|
log 'dry-run: pass --version VERSION to preview an exact artifact'
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -753,14 +756,15 @@ main() {
|
|||||||
artifact_url="$RELEASE_BASE_URL/$artifact"
|
artifact_url="$RELEASE_BASE_URL/$artifact"
|
||||||
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
|
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
|
||||||
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
|
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
|
||||||
if (( ! APPLY )); then log 'dry-run: pass --apply to download, verify, extract, and configure systemd'; return 0; fi
|
if (( ! APPLY )); then log 'dry-run: no download, extraction, or systemd changes'; return 0; fi
|
||||||
[[ "$REQUIRE_SIGNATURE" == true || "$ALLOW_UNSIGNED" -eq 1 ]] || die '生产安装必须校验发布签名;仅隔离开发环境可使用 --allow-unsigned'
|
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行'
|
||||||
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行 --apply'
|
[[ $EUID -eq 0 ]] || die '安装必须以 root 运行'
|
||||||
[[ $EUID -eq 0 ]] || die '--apply must run as root'
|
|
||||||
for command_name in curl sha256sum tar install sed awk find systemctl; do
|
for command_name in curl sha256sum tar install sed awk find systemctl; do
|
||||||
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
|
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
|
||||||
done
|
done
|
||||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||||
|
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled'
|
||||||
|
fi
|
||||||
work=$(mktemp -d)
|
work=$(mktemp -d)
|
||||||
INSTALL_WORK_DIR=$work
|
INSTALL_WORK_DIR=$work
|
||||||
INSTALL_BACKUP_DIR="$work/original"
|
INSTALL_BACKUP_DIR="$work/original"
|
||||||
@@ -784,8 +788,9 @@ main() {
|
|||||||
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
|
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
|
||||||
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
|
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
|
||||||
fi
|
fi
|
||||||
|
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
||||||
signature=''
|
signature=''
|
||||||
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
|
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" ]]; then
|
||||||
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
|
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
|
||||||
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
|
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
|
||||||
signature="$work/$artifact.asc"
|
signature="$work/$artifact.asc"
|
||||||
@@ -794,11 +799,7 @@ main() {
|
|||||||
signature="$work/SHA256SUMS.sig"
|
signature="$work/SHA256SUMS.sig"
|
||||||
fi
|
fi
|
||||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
||||||
elif [[ -n "$SIGNATURE_URL" ]]; then
|
|
||||||
signature="$work/SHA256SUMS.sig"
|
|
||||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
|
||||||
fi
|
fi
|
||||||
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
|
||||||
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
|
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
|
||||||
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
|
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
|
||||||
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
|
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
|
||||||
@@ -851,10 +852,14 @@ main() {
|
|||||||
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
|
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
|
||||||
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
|
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
|
||||||
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
|
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
|
||||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
|
|
||||||
# The bootstrap verification key is also the key used by the privileged
|
# The bootstrap verification key is also the key used by the privileged
|
||||||
# updater unless the operator already configured a separate one.
|
# updater unless the operator already configured a separate one.
|
||||||
UPDATE_PUBLIC_KEY_FILE=${UPDATE_PUBLIC_KEY_FILE:-$SIGNING_KEY}
|
UPDATE_PUBLIC_KEY_FILE=${UPDATE_PUBLIC_KEY_FILE:-$SIGNING_KEY}
|
||||||
|
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||||
|
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
|
||||||
|
else
|
||||||
|
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE false
|
||||||
|
fi
|
||||||
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||||
validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径'
|
validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径'
|
||||||
[[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid'
|
[[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid'
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tallynote",
|
"name": "tallynote",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@9.0.6",
|
"packageManager": "pnpm@9.0.6",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
# Publish one immutable, signed release to a Gitea-compatible API. The script
|
# Publish one immutable release to a Gitea-compatible API. SHA256SUMS is
|
||||||
# is intentionally separate from the workflow so operators can dry-run the
|
# always generated; an Ed25519 detached signature is added when a signing key
|
||||||
# exact same asset selection locally without ever exposing a signing key.
|
# is supplied. The script remains separate from the workflow so operators can
|
||||||
|
# dry-run the exact same asset selection locally without exposing a key.
|
||||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||||
export PATH
|
export PATH
|
||||||
umask 077
|
umask 077
|
||||||
@@ -23,6 +24,7 @@ DRY_RUN=0
|
|||||||
AUTH_CONFIG=''
|
AUTH_CONFIG=''
|
||||||
SUMS_TMP=''
|
SUMS_TMP=''
|
||||||
SIG_TMP=''
|
SIG_TMP=''
|
||||||
|
SIGNATURE_GENERATED=0
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -30,8 +32,12 @@ Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
|
|||||||
|
|
||||||
Required in publish mode:
|
Required in publish mode:
|
||||||
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
|
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
|
||||||
|
|
||||||
|
Optional:
|
||||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
|
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
|
||||||
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||||
|
|
||||||
|
Without a signing key, the release is published with SHA256SUMS only.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
|
||||||
@@ -116,7 +122,9 @@ API_ROOT=${API_ROOT%/}
|
|||||||
validate_api_root "$API_ROOT"
|
validate_api_root "$API_ROOT"
|
||||||
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
|
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
|
||||||
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
|
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
|
||||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
if [[ -n "$SIGNING_KEY_FILE" || -n "$SIGNING_KEY_VALUE" ]]; then
|
||||||
|
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signing a release'
|
||||||
|
fi
|
||||||
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
||||||
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
|
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
|
||||||
|
|
||||||
@@ -164,8 +172,6 @@ elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
|
|||||||
chmod 600 "$temporary_key"
|
chmod 600 "$temporary_key"
|
||||||
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
|
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
|
||||||
unset SIGNING_KEY_VALUE
|
unset SIGNING_KEY_VALUE
|
||||||
else
|
|
||||||
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
|
|
||||||
fi
|
fi
|
||||||
if [[ -n "$temporary_key" ]]; then
|
if [[ -n "$temporary_key" ]]; then
|
||||||
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
|
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
|
||||||
@@ -174,16 +180,18 @@ if [[ -n "$temporary_key" ]]; then
|
|||||||
chmod 600 "$SIG_TMP"
|
chmod 600 "$SIG_TMP"
|
||||||
mv -f -- "$SIG_TMP" "$SIG_FILE"
|
mv -f -- "$SIG_TMP" "$SIG_FILE"
|
||||||
SIG_TMP=''
|
SIG_TMP=''
|
||||||
|
SIGNATURE_GENERATED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "tag: $TAG"
|
log "tag: $TAG"
|
||||||
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
|
asset_summary="assets: ${#assets[@]} archive(s), SHA256SUMS"
|
||||||
|
if (( SIGNATURE_GENERATED )); then asset_summary+=", SHA256SUMS.sig"; fi
|
||||||
|
log "$asset_summary"
|
||||||
if (( DRY_RUN )); then
|
if (( DRY_RUN )); then
|
||||||
log 'dry-run: no API request was sent'
|
log 'dry-run: no API request was sent'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
|
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
|
||||||
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
|
|
||||||
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
|
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
|
||||||
write_auth_config
|
write_auth_config
|
||||||
unset TOKEN
|
unset TOKEN
|
||||||
@@ -230,7 +238,8 @@ assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
|
|||||||
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
|
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
|
||||||
while IFS=$'\t' read -r existing_id existing_name; do
|
while IFS=$'\t' read -r existing_id existing_name; do
|
||||||
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
|
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
|
||||||
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; do
|
candidates=("${assets[@]}" "$SUMS_FILE" "$SIG_FILE")
|
||||||
|
for candidate in "${candidates[@]}"; do
|
||||||
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
|
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
|
||||||
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
|
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
|
||||||
done
|
done
|
||||||
@@ -245,5 +254,7 @@ upload_asset() {
|
|||||||
}
|
}
|
||||||
for file in "${assets[@]}"; do upload_asset "$file"; done
|
for file in "${assets[@]}"; do upload_asset "$file"; done
|
||||||
upload_asset "$SUMS_FILE"
|
upload_asset "$SUMS_FILE"
|
||||||
upload_asset "$SIG_FILE"
|
if (( SIGNATURE_GENERATED )); then
|
||||||
|
upload_asset "$SIG_FILE"
|
||||||
|
fi
|
||||||
log "published $TAG to $REPOSITORY"
|
log "published $TAG to $REPOSITORY"
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ fi
|
|||||||
# main invocation lets this subprocess source the exact production code.
|
# main invocation lets this subprocess source the exact production code.
|
||||||
installer_lib="$tmp/install-lib.sh"
|
installer_lib="$tmp/install-lib.sh"
|
||||||
sed '$d' "$root/install.sh" > "$installer_lib"
|
sed '$d' "$root/install.sh" > "$installer_lib"
|
||||||
|
bash -c '
|
||||||
|
script=$1
|
||||||
|
set --
|
||||||
|
source "$script"
|
||||||
|
[[ "$APPLY" -eq 1 ]]
|
||||||
|
[[ "$REQUIRE_SIGNATURE" == false ]]
|
||||||
|
' _ "$installer_lib"
|
||||||
bash -c '
|
bash -c '
|
||||||
script=$1
|
script=$1
|
||||||
mode_dir=$2
|
mode_dir=$2
|
||||||
@@ -94,6 +101,20 @@ bash -c '
|
|||||||
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
|
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
|
||||||
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
|
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
|
||||||
|
|
||||||
|
# A normal public-release install only needs the detached SHA-256 manifest;
|
||||||
|
# absence of a signature and public key must not block archive verification.
|
||||||
|
checksum_tmp="$tmp/SHA256SUMS"
|
||||||
|
(cd "$(dirname -- "$archive_tmp")" && sha256sum "$(basename -- "$archive_tmp")") > "$checksum_tmp"
|
||||||
|
bash -c '
|
||||||
|
script=$1
|
||||||
|
archive=$2
|
||||||
|
checksum=$3
|
||||||
|
set --
|
||||||
|
source "$script"
|
||||||
|
REQUIRE_SIGNATURE=false
|
||||||
|
verify_archive "$archive" "$checksum" "" ""
|
||||||
|
' _ "$installer_lib" "$archive_tmp" "$checksum_tmp"
|
||||||
|
|
||||||
# Newline/control characters in release configuration must never become extra
|
# Newline/control characters in release configuration must never become extra
|
||||||
# systemd EnvironmentFile assignments.
|
# systemd EnvironmentFile assignments.
|
||||||
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
|||||||
...(deferCompletion ? { deferCompletion: true } : {}),
|
...(deferCompletion ? { deferCompletion: true } : {}),
|
||||||
...(request ? { jobId: request.jobId } : {}),
|
...(request ? { jobId: request.jobId } : {}),
|
||||||
publicKey: config.updatePublicKey,
|
publicKey: config.updatePublicKey,
|
||||||
requireSignature: request ? true : config.updateRequireSignature,
|
requireSignature: config.updateRequireSignature,
|
||||||
sqlite: database.sqlite,
|
sqlite: database.sqlite,
|
||||||
});
|
});
|
||||||
console.log(`更新完成:${result.version}`);
|
console.log(`更新完成:${result.version}`);
|
||||||
|
|||||||
+4
-1
@@ -104,7 +104,10 @@ export function loadConfig() {
|
|||||||
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
|
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
|
||||||
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
|
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
|
||||||
const updatePublicKey = updatePublicKeyEnv();
|
const updatePublicKey = updatePublicKeyEnv();
|
||||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
|
// Public releases always require HTTPS, host allowlisting, and SHA-256.
|
||||||
|
// Detached signatures remain an opt-in hardening layer so a self-hosted
|
||||||
|
// public repository can use one-click updates without provisioning a key.
|
||||||
|
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", false);
|
||||||
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
|
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
|
||||||
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
|
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
|
|||||||
TALLYNOTE_UPDATE_STRATEGY=systemd
|
TALLYNOTE_UPDATE_STRATEGY=systemd
|
||||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||||
# Configure a root-managed Ed25519 public key before enabling one-click updates.
|
# Optional: configure a root-managed Ed25519 public key and set
|
||||||
|
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
|
||||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||||
|
|||||||
@@ -27,14 +27,16 @@ describe("部署安全配置", () => {
|
|||||||
expect(loadConfig().trustProxy).toBe(1);
|
expect(loadConfig().trustProxy).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
|
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
|
||||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
|
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
|
||||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||||
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
|
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
|
||||||
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
|
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
expect(config.updateRequireSignature).toBe(true);
|
expect(config.updateRequireSignature).toBe(false);
|
||||||
|
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
|
||||||
|
expect(loadConfig().updateRequireSignature).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
|
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ describe("更新 API", () => {
|
|||||||
|
|
||||||
function mockRelease() {
|
function mockRelease() {
|
||||||
const digest = "c".repeat(64);
|
const digest = "c".repeat(64);
|
||||||
const asset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
|
const asset = `tallynote-1.1.2-${detectPlatform().target}-glibc.tar.gz`;
|
||||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
|
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS")
|
||||||
? new Response(`${digest} ${asset}\n`, { status: 200 })
|
? new Response(`${digest} ${asset}\n`, { status: 200 })
|
||||||
: new Response(JSON.stringify({ tag_name: "v1.1.1", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
: new Response(JSON.stringify({ tag_name: "v1.1.2", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
|
it("检查 release、创建受保护请求文件并拒绝重复任务", async () => {
|
||||||
@@ -70,21 +70,21 @@ describe("更新 API", () => {
|
|||||||
mockRelease();
|
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: {} });
|
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.statusCode).toBe(200);
|
||||||
expect(checked.json().latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, isNewer: true });
|
expect(checked.json().latest).toMatchObject({ version: "1.1.2", compatible: true, integrityReady: true, isNewer: true });
|
||||||
expect(checked.headers["cache-control"]).toBe("no-store");
|
expect(checked.headers["cache-control"]).toBe("no-store");
|
||||||
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||||
expect(tooSoon.statusCode).toBe(429);
|
expect(tooSoon.statusCode).toBe(429);
|
||||||
expect(tooSoon.headers["retry-after"]).toBeDefined();
|
expect(tooSoon.headers["retry-after"]).toBeDefined();
|
||||||
|
|
||||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||||
expect(applied.statusCode).toBe(202);
|
expect(applied.statusCode).toBe(202);
|
||||||
const jobId = applied.json().job.id as string;
|
const jobId = applied.json().job.id as string;
|
||||||
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
|
const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string };
|
||||||
expect(request).toMatchObject({ jobId, version: "1.1.1", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
expect(request).toMatchObject({ jobId, version: "1.1.2", expectedSha256: "c".repeat(64), currentLink: config.currentLink });
|
||||||
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
|
expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600);
|
||||||
|
|
||||||
mockRelease();
|
mockRelease();
|
||||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||||
expect(duplicate.statusCode).toBe(409);
|
expect(duplicate.statusCode).toBe(409);
|
||||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||||
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||||
@@ -95,7 +95,7 @@ describe("更新 API", () => {
|
|||||||
|
|
||||||
it("缺少确认或未启用 systemd 时不接受更新", async () => {
|
it("缺少确认或未启用 systemd 时不接受更新", async () => {
|
||||||
const session = await login();
|
const session = await login();
|
||||||
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1" } });
|
const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2" } });
|
||||||
expect(invalid.statusCode).toBe(400);
|
expect(invalid.statusCode).toBe(400);
|
||||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
||||||
const disabledConfig = loadConfig();
|
const disabledConfig = loadConfig();
|
||||||
@@ -108,7 +108,7 @@ describe("更新 API", () => {
|
|||||||
mockRelease();
|
mockRelease();
|
||||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
|
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} });
|
||||||
expect(checked.statusCode).toBe(200);
|
expect(checked.statusCode).toBe(200);
|
||||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.1", confirm: true } });
|
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||||
expect(applied.statusCode).toBe(202);
|
expect(applied.statusCode).toBe(202);
|
||||||
const jobId = applied.json().job.id as string;
|
const jobId = applied.json().job.id as string;
|
||||||
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
|
database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId);
|
||||||
@@ -126,7 +126,7 @@ describe("更新 API", () => {
|
|||||||
it("应用前重新校验失败时写入失败审计", async () => {
|
it("应用前重新校验失败时写入失败审计", async () => {
|
||||||
const session = await login("update-audit");
|
const session = await login("update-audit");
|
||||||
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch;
|
||||||
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } });
|
const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } });
|
||||||
expect(response.statusCode).toBe(502);
|
expect(response.statusCode).toBe(502);
|
||||||
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
||||||
expect(audit?.outcome).toBe("failure");
|
expect(audit?.outcome).toBe("failure");
|
||||||
|
|||||||
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
|
|||||||
prepareDataDirectories(config);
|
prepareDataDirectories(config);
|
||||||
const database = openDatabase(config);
|
const database = openDatabase(config);
|
||||||
const digest = "b".repeat(64);
|
const digest = "b".repeat(64);
|
||||||
const platformAsset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`;
|
const platformAsset = `tallynote-1.1.2-${detectPlatform().target}-glibc.tar.gz`;
|
||||||
const sums = `${digest} ${platformAsset}\n`;
|
const sums = `${digest} ${platformAsset}\n`;
|
||||||
const signature = sign(null, Buffer.from(sums), privateKey);
|
const signature = sign(null, Buffer.from(sums), privateKey);
|
||||||
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig")
|
||||||
? new Response(signature)
|
? new Response(signature)
|
||||||
: input.toString().endsWith("SHA256SUMS")
|
: input.toString().endsWith("SHA256SUMS")
|
||||||
? new Response(sums)
|
? new Response(sums)
|
||||||
: new Response(JSON.stringify({ tag_name: "v1.1.1", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
: new Response(JSON.stringify({ tag_name: "v1.1.2", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch;
|
||||||
try {
|
try {
|
||||||
const result = await checkForUpdate(database.sqlite, config);
|
const result = await checkForUpdate(database.sqlite, config);
|
||||||
expect(result.latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
expect(result.latest).toMatchObject({ version: "1.1.2", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
|
||||||
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
|
const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string };
|
||||||
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
|
expect(JSON.parse(cached.value).asset.sha256).toBe(digest);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user