diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 94a3687..1b0db21 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -1,17 +1,26 @@ --- -description: 执行本地发版流程(build → test → CHANGELOG → commit → tag → push) -argument-hint: 如 1.0.22 +description: 执行某一条流水线的本地发版(part ∈ client|site|server) +argument-hint: [version] 如 `client 1.0.55` 或 `server` model: claude-sonnet-4-6 allowed-tools: Bash, Read, Edit, Write --- -执行本地发版流程。 +执行三条独立流水线之一的本地发版流程。`$ARGUMENTS` 形如 ` [version]`: -**确定版本号:** -- 如果 `$ARGUMENTS` 非空,使用它作为版本号(VERSION)。 -- 如果 `$ARGUMENTS` 为空,运行 `git describe --tags --abbrev=0` 获取最新 tag(如 `v1.0.20`),去掉 `v` 前缀后将 patch 号加 1(如 `1.0.21`),作为版本号(VERSION)。将自动确定的版本号告知用户。 +- **part**(必填):`client` | `site` | `server`,分别对应 tag 前缀 `client-v*` / `site-v*` / `server-v*`、CHANGELOG 文件 `CHANGELOG-client.md` / `CHANGELOG-site.md` / `CHANGELOG-server.md`。 +- **version**(选填):如 `1.0.55`。 -后续所有步骤中,将确定好的版本号记为 VERSION。 +**三者互不影响**:client 发版只动 app 产物与 `version.yaml`(官网下载页与后端 `/version` 自动同步,无需重建官网/重启后端);site 只重建营销站;server 只换后端二进制 + nginx。 + +## 0. 解析参数 + +- 校验 part 合法(client|site|server),非法立即报错退出。 +- 记 PART = part。 + +**确定版本号 VERSION:** +- 给了 version:直接用。 +- 没给:`git describe --tags --match "-v*" --abbrev=0` 取该前缀最新 tag(如 `client-v1.0.54`),去掉 `-v` 前缀后 patch +1(如 `1.0.55`)。若该前缀尚无任何 tag,则 VERSION=`1.0.0`。将自动确定的版本号告知用户。 +- 记 TAG = `-v`,CHANGELOG_FILE = `CHANGELOG-.md`。 按以下步骤顺序执行,任意步骤失败立即停止并报告错误。 @@ -19,27 +28,31 @@ allowed-tools: Bash, Read, Edit, Write 检查是否有未提交的改动(`git status`)。如果有,列出文件并询问用户是否继续。 -## 2. 本地构建 +## 2. 本地构建 + 测试门禁(按 part) ```bash export PATH="/opt/homebrew/bin:$PATH" -cd backend && go build ./... ``` -## 3. 运行测试 +- **server**: + ```bash + cd backend && go build ./... && go vet ./... && go test ./... + ``` +- **client**: + ```bash + cd client && flutter analyze --no-fatal-infos --no-fatal-warnings + ``` +- **site**: + ```bash + cd web && npm ci && npm run build + ``` -```bash -export PATH="/opt/homebrew/bin:$PATH" -cd backend && go test ./... -cd client && flutter analyze --no-fatal-infos --no-fatal-warnings -``` +## 3. 更新 CHANGELOG_FILE -## 4. 更新 CHANGELOG.md - -检查 CHANGELOG.md 中是否已有 `## [VERSION]` 版本节。 +检查 CHANGELOG_FILE 中是否已有 `## [VERSION]` 版本节。 - **已有**:直接使用,不修改。 -- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入到文件顶部(位于已有版本节之前),格式如下: +- **没有**:读取该 part 上一个 tag(`-v*`)到 HEAD 的 `git log`,自动生成版本节插入文件顶部(位于已有版本节之前),格式: ``` ## [VERSION] - <今天日期> @@ -54,36 +67,33 @@ cd client && flutter analyze --no-fatal-infos --no-fatal-warnings - ... ``` -只保留有内容的分类(新功能/改进/修复),根据 commit message 的类型(feat/fix/refactor 等)归类。 +只保留有内容的分类,按 commit 类型(feat/fix/refactor 等)归类。 **CHANGELOG 撰写原则:** -- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现 -- **忽略**以下类型的提交(产品感知弱): - - 发版工具、CI/CD、部署脚本相关(chore: release、ci:、devops:) - - 内部文档、开发规范、CLAUDE.md 更新(docs(claude):、docs(internal):) - - 代码格式、lint、重构(style:、refactor: 纯内部) - - 依赖升级、版本号 bump(chore: bump、chore: upgrade) -- **保留并重点描述**:feat、fix、perf 类,以及用户能感知到的 refactor(如界面改版、交互优化) -- 每条描述用一句话说清楚"用户能做什么"或"修了什么问题",不堆砌技术术语 +- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现。 +- **忽略**产品感知弱的提交:发版/CI/部署(chore: release、ci:、devops:)、内部文档/规范(docs(claude):、docs(internal):)、纯格式/重构(style:、refactor:)、依赖升级(chore: bump)。 +- **保留并重点描述** feat / fix / perf,以及用户可感知的 refactor(界面改版、交互优化)。 +- 每条一句话说清"用户能做什么"或"修了什么问题"。 +- **范围按 part 过滤**:client 只收 `client/` 相关;server 只收 `backend/` 相关;site 只收 `web/`(营销站)相关。 -## 5. 提交代码 +## 4. 提交代码 -将所有改动(包括 CHANGELOG.md)用以下格式提交到 main: +将所有改动(含 CHANGELOG_FILE)提交到 main: ``` -chore: release vVERSION +chore: release -v ``` -## 6. 打 tag +## 5. 打 tag ```bash -git tag vVERSION +git tag -v ``` -## 7. 推送 +## 6. 推送 ```bash -git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main vVERSION +git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main -v ``` -推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。 +推送成功后提示用户:对应流水线(deploy-)已触发,等待 Telegram 通知。 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy-client.yml similarity index 79% rename from .gitea/workflows/deploy.yml rename to .gitea/workflows/deploy-client.yml index 8883fa8..307581c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy-client.yml @@ -1,12 +1,16 @@ -name: Deploy +name: Deploy Client on: push: tags: - - 'v[0-9]*.[0-9]*.[0-9]*' + - 'client-v[0-9]*.[0-9]*.[0-9]*' + +concurrency: + group: deploy-client + cancel-in-progress: false jobs: - build-linux-web: + build-client-web: runs-on: mac env: GOPROXY: https://goproxy.cn,direct @@ -19,19 +23,17 @@ jobs: - name: Provision (idempotent — auto-runs if host not initialized) run: sh scripts/ci/provision-mac.sh - - name: Compile (Linux backend + Flutter Web) - run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}" + - name: Compile (Flutter Web) + run: sh scripts/ci/compile-client-web.sh "${{ gitea.ref_name }}" - - name: Upload linux-web artifacts + - name: Upload web artifacts uses: actions/upload-artifact@v3 with: - name: linux-web + name: client-web path: dist/ build-macos: - # Same physical runner as build-linux-web (label: mac, capacity 1). Run them - # serially via needs so the second job doesn't sit queued and time out. - needs: build-linux-web + needs: build-client-web runs-on: mac env: PUB_HOSTED_URL: https://pub.flutter-io.cn @@ -53,9 +55,6 @@ jobs: path: dist/ build-android: - # Same physical mac runner (label: mac, capacity 1) — it already has the - # Android SDK. Serialize after build-macos via needs so it doesn't sit - # queued and time out. needs: build-macos runs-on: mac env: @@ -83,10 +82,6 @@ jobs: path: dist/ build-ios: - # Same physical mac runner (label: mac, capacity 1) with Xcode. Serialize - # after build-android. Builds a signed IPA and uploads to TestFlight; if the - # iOS signing secrets are absent the script skips gracefully (exit 0), so this - # job is safe to keep before the Apple Developer account is configured. needs: build-android runs-on: mac env: @@ -134,8 +129,8 @@ jobs: name: windows path: dist/ - release-deploy: - needs: [build-linux-web, build-macos, build-android, build-ios, build-windows] + release-deploy-client: + needs: [build-client-web, build-macos, build-android, build-ios, build-windows] runs-on: mac env: GOPROXY: https://goproxy.cn,direct @@ -145,10 +140,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Download linux-web artifacts + - name: Download web artifacts uses: actions/download-artifact@v3 with: - name: linux-web + name: client-web path: dist/ - name: Download macos artifacts @@ -169,15 +164,15 @@ jobs: name: windows path: dist/ - - name: Test - run: sh scripts/ci/test.sh + - name: Test (flutter analyze) + run: sh scripts/ci/test.sh client - name: Release env: FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }} GITEA_REPOSITORY: ${{ gitea.repository }} - run: sh scripts/ci/release.sh "${{ gitea.ref_name }}" + run: sh scripts/ci/release-client.sh "${{ gitea.ref_name }}" - name: Deploy env: @@ -187,7 +182,7 @@ jobs: EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} EC2_HOST: ${{ secrets.EC2_HOST }} EC2_USER: ${{ secrets.EC2_USER }} - run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}" + run: sh scripts/ci/deploy-client.sh "${{ gitea.ref_name }}" - name: Notify if: always() diff --git a/.gitea/workflows/deploy-server.yml b/.gitea/workflows/deploy-server.yml new file mode 100644 index 0000000..e7d234b --- /dev/null +++ b/.gitea/workflows/deploy-server.yml @@ -0,0 +1,54 @@ +name: Deploy Server + +on: + push: + tags: + - 'server-v[0-9]*.[0-9]*.[0-9]*' + +concurrency: + group: deploy-server + cancel-in-progress: false + +jobs: + release-deploy-server: + runs-on: mac + env: + GOPROXY: https://goproxy.cn,direct + PUB_HOSTED_URL: https://pub.flutter-io.cn + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + + steps: + - uses: actions/checkout@v4 + + - name: Provision (idempotent — auto-runs if host not initialized) + run: sh scripts/ci/provision-mac.sh + + - name: Compile (Linux backend + configs) + run: sh scripts/ci/compile-backend.sh "${{ gitea.ref_name }}" + + - name: Test (go test) + run: sh scripts/ci/test.sh server + + - name: Release + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}" + + - name: Deploy + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + GITEA_REPOSITORY: ${{ gitea.repository }} + EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}" + + - name: Notify + if: always() + env: + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}" diff --git a/.gitea/workflows/deploy-site.yml b/.gitea/workflows/deploy-site.yml new file mode 100644 index 0000000..93d75e4 --- /dev/null +++ b/.gitea/workflows/deploy-site.yml @@ -0,0 +1,50 @@ +name: Deploy Site + +on: + push: + tags: + - 'site-v[0-9]*.[0-9]*.[0-9]*' + +concurrency: + group: deploy-site + cancel-in-progress: false + +jobs: + release-deploy-site: + runs-on: mac + env: + PUB_HOSTED_URL: https://pub.flutter-io.cn + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + + steps: + - uses: actions/checkout@v4 + + - name: Provision (idempotent — auto-runs if host not initialized) + run: sh scripts/ci/provision-mac.sh + + - name: Compile (Eleventy marketing site) + run: sh scripts/ci/compile-site.sh "${{ gitea.ref_name }}" + + - name: Release + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: sh scripts/ci/release-site.sh "${{ gitea.ref_name }}" + + - name: Deploy + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + GITEA_REPOSITORY: ${{ gitea.repository }} + EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + run: sh scripts/ci/deploy-site.sh "${{ gitea.ref_name }}" + + - name: Notify + if: always() + env: + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}" diff --git a/.gitea/workflows/manual.yml b/.gitea/workflows/manual.yml index 91076b2..b34e120 100644 --- a/.gitea/workflows/manual.yml +++ b/.gitea/workflows/manual.yml @@ -1,10 +1,16 @@ name: Manual Deploy +# Re-deploy a previously released tag. The tag prefix selects which part: +# client-v* -> deploy-client.sh (web app + installers + version.yaml) +# site-v* -> deploy-site.sh (marketing site) +# server-v* -> deploy-server.sh (backend binary + nginx) +# Each deploy script downloads its assets from the matching Forgejo Release. + on: workflow_dispatch: inputs: version: - description: 'Tag to deploy (e.g. v1.0.0)' + description: 'Prefixed tag to deploy (e.g. client-v1.0.55 / site-v1.0.0 / server-v1.0.0)' required: true type: string @@ -17,7 +23,7 @@ jobs: with: ref: ${{ inputs.version }} - - name: Deploy + - name: Deploy (routed by tag prefix) env: FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }} @@ -25,4 +31,18 @@ jobs: EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} EC2_HOST: ${{ secrets.EC2_HOST }} EC2_USER: ${{ secrets.EC2_USER }} - run: sh scripts/ci/deploy.sh "${{ inputs.version }}" + run: | + TAG="${{ inputs.version }}" + case "$TAG" in + client-v*) sh scripts/ci/deploy-client.sh "$TAG" ;; + site-v*) sh scripts/ci/deploy-site.sh "$TAG" ;; + server-v*) sh scripts/ci/deploy-server.sh "$TAG" ;; + *) echo "Unknown tag prefix: $TAG (expected client-v* / site-v* / server-v*)" >&2; exit 1 ;; + esac + + - name: Notify + if: always() + env: + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: sh scripts/ci/notify.sh "${{ inputs.version }}" "${{ job.status }}" diff --git a/CHANGELOG.md b/CHANGELOG-client.md similarity index 100% rename from CHANGELOG.md rename to CHANGELOG-client.md diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md new file mode 100644 index 0000000..a8a5962 --- /dev/null +++ b/CHANGELOG-server.md @@ -0,0 +1,11 @@ +# Changelog — Server(后端) + +后端服务(`backend/`,`jiu-server`)的更新记录。tag 前缀 `server-v*`。 + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-06-17 + +### 新功能 +- 后端服务独立发版流水线启用(`server-v*`),与客户端、官网互不影响 diff --git a/CHANGELOG-site.md b/CHANGELOG-site.md new file mode 100644 index 0000000..0b00e5e --- /dev/null +++ b/CHANGELOG-site.md @@ -0,0 +1,12 @@ +# Changelog — Site(官网) + +营销宣传站(`web/`,Eleventy)的更新记录。tag 前缀 `site-v*`。 +**不含** Web 版 app(Web 版 app 属于客户端,见 `CHANGELOG-client.md`)。 + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-06-17 + +### 新功能 +- 官网独立发版流水线启用(`site-v*`),与客户端、后端互不影响 diff --git a/CLAUDE.md b/CLAUDE.md index f5d0031..64d74f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,22 +261,36 @@ cd client && flutter test ### 发版流程 -使用 `/release ` slash command: +**发版拆成三条互不影响的流水线**,各有 tag 前缀、独立版本序列、独立 CHANGELOG: + +| part | 范围 | tag 前缀 | CHANGELOG | workflow | +|------|------|---------|-----------|----------| +| **client** | `client/` Flutter 全平台(Web→`/app`、macOS、Windows、Android、iOS)+ 应用自更新清单 `version.yaml` | `client-v*` | `CHANGELOG-client.md` | `deploy-client.yml` | +| **site** | `web/` Eleventy 营销宣传站(→`/opt/jiu/marketing`,nginx `/`)。**不含** Web 版 app | `site-v*` | `CHANGELOG-site.md` | `deploy-site.yml` | +| **server** | `backend/` Go 服务(`jiu-server`)+ 共享基建 nginx-jiu.conf / jiu.service | `server-v*` | `CHANGELOG-server.md` | `deploy-server.yml` | + +使用 `/release [version]` slash command: ``` -/release 1.0.2 +/release client 1.0.55 # 指定版本 +/release server # 省略则自增 server-v* 最新 tag 的 patch ``` -执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。 -CI/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。 +执行顺序:本地 build → test(按 part)→ 更新对应 CHANGELOG → git commit → tag `-v` → push main+tag。 +CI/CD(Forgejo)按 tag 前缀触发对应 workflow,自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。 -**多平台构建矩阵**(脚本在 `scripts/ci/`): -- mac runner(容量 1)串行链:`build-linux-web`(后端+Web+官网)→ `build-macos` → `build-android` → `build-ios` → `release-deploy` -- windows runner 并行:`build-windows`(Inno Setup 打包 setup.exe) -- 分发:桌面(Win/macOS) 发布到下载页 `/downloads/` + 应用内更新;**Android** APK 同样挂 `/downloads/`,需 `ANDROID_*` 签名 secrets(见 `docs/android-signing.md`);**iOS** 走 TestFlight,需 `IOS_*` / `APPSTORE_*` secrets(见 `docs/ios-signing.md`) -- Android/iOS 的 job 在对应 secrets 未配置时**优雅跳过**(exit 0,不阻塞发版);本地无 `client/android/key.properties` 时 APK 自动回退 debug 签名 +**归属与解耦**(关键): +- `version.yaml` 归 **client**(写 version/build_number/release_notes/下载链接/changelog);nginx/systemd 归 **server**。 +- 后端 `/version` 与 `/api/v1/public/release` **每请求实时读** `version.yaml`,无缓存——client 部署 version.yaml 后立即生效,**不重启后端、不触发 server 流水线**。 +- 官网下载页运行时 `fetch('/api/v1/public/release')`:版本徽章、各平台下载链接、**更新日志时间线**全部动态刷新——client 发版**无需重建官网**。接口不可达时回退到构建时静态内容(`web/_data/changelog.js` 读 `CHANGELOG-client.md`)。 -**CHANGELOG 格式**(Keep a Changelog): +**CI 脚本**(`scripts/ci/`):`lib-forgejo.sh`(公共函数)+ `compile-{client-web,site,backend,macos,android,ios,windows}.sh` + `release-{client,site,server}.sh` + `deploy-{client,site,server}.sh`。`compile-{macos,android,ios,windows}.sh` 去 `client-v` 前缀取版本。 + +**client 多平台构建矩阵**:mac runner(容量 1)串行链 `build-client-web → build-macos → build-android → build-ios`;windows runner 并行 `build-windows`(Inno Setup);`release-deploy-client` 收齐后发布。Android 需 `ANDROID_*`、iOS 走 TestFlight 需 `IOS_*`/`APPSTORE_*` secrets,未配置时该 job 优雅跳过(exit 0,不阻塞)。 + +**手动回滚**:`manual.yml` 输入带前缀 tag(如 `client-v1.0.54`),按前缀路由到对应 `deploy-.sh`,从 Forgejo Release 下载产物。 + +**CHANGELOG 格式**(Keep a Changelog,三个文件同构): ```markdown ## [1.0.2] - YYYY-MM-DD @@ -289,7 +303,7 @@ CI/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 ### 修复 - ... ``` -只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。 +只保留有内容的分类。`/release` 会检查对应 CHANGELOG 是否已有该版本节,没有则从该 part 上一个 tag 的 git log 自动生成。 ### 异常上报 diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index 2d33359..f389ce1 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -183,6 +183,7 @@ func (h *PublicHandler) GetRelease(c *gin.Context) { "version": "1.0.0", "release_notes": "", "download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"}, + "changelog": []changelogEntry{}, }) return } @@ -190,6 +191,7 @@ func (h *PublicHandler) GetRelease(c *gin.Context) { "version": cfg.Version, "release_notes": cfg.ReleaseNotes, "download_urls": cfg.DownloadURLs, + "changelog": changelogOrEmpty(cfg.Changelog), }) } diff --git a/backend/internal/handler/version.go b/backend/internal/handler/version.go index 33059f7..af9bf8e 100644 --- a/backend/internal/handler/version.go +++ b/backend/internal/handler/version.go @@ -17,6 +17,29 @@ type versionConfig struct { ForceUpdate bool `yaml:"force_update"` ReleaseNotes string `yaml:"release_notes"` DownloadURLs map[string]string `yaml:"download_urls"` + // Changelog 由 client 发版脚本从 CHANGELOG-client.md 写入(最近 3 条), + // 供官网下载页运行时拉取渲染更新日志,无需重建官网。 + Changelog []changelogEntry `yaml:"changelog"` +} + +type changelogEntry struct { + Version string `yaml:"version" json:"version"` + Date string `yaml:"date" json:"date"` + Intro string `yaml:"intro" json:"intro"` + Sections []changelogSection `yaml:"sections" json:"sections"` +} + +type changelogSection struct { + Type string `yaml:"type" json:"type"` + Items []string `yaml:"items" json:"items"` +} + +// changelogOrEmpty 保证响应里 changelog 始终是数组(而非 null),方便前端遍历。 +func changelogOrEmpty(c []changelogEntry) []changelogEntry { + if c == nil { + return []changelogEntry{} + } + return c } // GetVersion GET /version @@ -33,6 +56,7 @@ func GetVersion(c *gin.Context) { "force_update": cfg.ForceUpdate, "release_notes": cfg.ReleaseNotes, "download_urls": cfg.DownloadURLs, + "changelog": changelogOrEmpty(cfg.Changelog), }) } diff --git a/docs/deployment-nas-gitea.md b/docs/deployment-nas-gitea.md index dc04c9d..d896bc1 100644 --- a/docs/deployment-nas-gitea.md +++ b/docs/deployment-nas-gitea.md @@ -36,7 +36,7 @@ Runner 实际运行在**本机 Mac**(不是 NAS),通过 LaunchAgent 开机 ### Workflow 标签 -`.gitea/workflows/deploy.yml` 和 `ci.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`。 +`.gitea/workflows/deploy-{client,site,server}.yml` 中使用 `runs-on: mac`,对应 runner label `mac:host`。 ### 检查 Runner 状态 @@ -89,19 +89,21 @@ kill $(pgrep -f act_runner_start.sh) 仓库根目录 `.gitea/workflows/` 下有三个 workflow 文件: -### `deploy.yml` — 主部署流程 +### `deploy-{client,site,server}.yml` — 三条独立部署流程 -**触发条件:** push `v*.*.*` tag +发版已拆成三条互不影响的流水线,按 tag 前缀触发: -**步骤:** -1. `compile.sh` — 编译 Go 后端(linux/amd64)+ Flutter Web,打包到 `dist/` -2. `test.sh` — `go test` + `flutter analyze` -3. `release.sh` — 解析 CHANGELOG.md,更新 version.yaml,创建 Forgejo Release,上传 3 个 asset -4. `deploy.sh` — SSH 到 EC2 部署,完成后发 Telegram 通知 +| workflow | 触发 tag | 范围 | +|----------|---------|------| +| `deploy-client.yml` | `client-v*.*.*` | Flutter 全平台 + version.yaml | +| `deploy-site.yml` | `site-v*.*.*` | Eleventy 营销站 | +| `deploy-server.yml` | `server-v*.*.*` | Go 后端 + nginx | + +**步骤(各自):** `compile-*.sh` 编译打包 → `test.sh ` 门禁 → `release-.sh` 创建 Forgejo Release → `deploy-.sh` SSH 到 EC2 部署 → Telegram 通知。公共函数在 `scripts/ci/lib-forgejo.sh`。 ### `manual.yml` — 手动部署 / 回滚 -**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入目标 tag(如 `v1.0.0`) +**触发条件:** Forgejo UI 手动触发(workflow_dispatch),输入带前缀 tag(如 `client-v1.0.55`),按前缀路由到对应 `deploy-.sh` **步骤:** 只运行 `deploy.sh`,从 Forgejo Release 下载已有产物直接部署,跳过编译和测试。 diff --git a/docs/ios-signing.md b/docs/ios-signing.md index 1921641..7f02f45 100644 --- a/docs/ios-signing.md +++ b/docs/ios-signing.md @@ -69,5 +69,5 @@ download_urls: ## 工作原理 - `scripts/ci/compile-ios.sh`:建临时 keychain 导入证书、安装 profile、生成 ExportOptions(manual / app-store)、`flutter build ipa`、`xcrun altool --upload-app` 上传;CFBundleVersion 由版本号推导单调递增;缺 secrets 时跳过。 -- `.gitea/workflows/deploy.yml` 的 `build-ios` job:mac runner,串行于 build-android。 +- `.gitea/workflows/deploy-client.yml` 的 `build-ios` job:mac runner,串行于 build-android。 - 工程:`client/ios`(bundle id `com.yanmei.jiu`,应用名「岩美酒库」)。 diff --git a/scripts/ci/compile-android.sh b/scripts/ci/compile-android.sh index 7cc1acf..bec6037 100755 --- a/scripts/ci/compile-android.sh +++ b/scripts/ci/compile-android.sh @@ -6,7 +6,7 @@ set -euo pipefail . "$(dirname "$0")/_env.sh" TAG="$1" -VER="${TAG#v}" +VER="${TAG#client-v}" # versionCode 必须单调递增,否则 Android 无法覆盖升级安装。 # 由版本号推导:major*10000 + minor*100 + patch(如 1.0.17 -> 10017)。 @@ -43,7 +43,7 @@ cd client flutter build apk --release \ "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ - "--dart-define=APP_VERSION=${TAG}" + "--dart-define=APP_VERSION=v${VER}" cd .. # Locate the built APK (path differs across Flutter versions) and copy to dist/ diff --git a/scripts/ci/compile-backend.sh b/scripts/ci/compile-backend.sh new file mode 100644 index 0000000..f31224e --- /dev/null +++ b/scripts/ci/compile-backend.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# compile-backend.sh — build the Linux backend binary + shared-infra +# configs.tar.gz into dist/. Server pipeline (server-v*). Does NOT touch +# version.yaml (that is client-owned) nor any Flutter/web artifact. +set -euo pipefail + +# shellcheck source=scripts/ci/_env.sh +. "$(dirname "$0")/_env.sh" + +TAG="$1" +echo "==> compile-backend: tag=${TAG}" + +# Build Go backend (linux/amd64 for EC2) +cd backend +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server . +cd .. + +mkdir -p dist +mv backend/jiu-server dist/jiu-server + +# Shared infrastructure (nginx/systemd/env/compose). version.yaml is NOT here — +# it belongs to the client pipeline. +tar -czf dist/configs.tar.gz \ + deploy/nginx-jiu.conf \ + deploy/jiu.service \ + deploy/production.env.template \ + deploy/setup-ec2.sh \ + deploy/docker-compose.yml \ + deploy/docker-compose.jiu.yml + +echo "==> compile-backend: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/compile-client-web.sh b/scripts/ci/compile-client-web.sh new file mode 100644 index 0000000..1971ea2 --- /dev/null +++ b/scripts/ci/compile-client-web.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# compile-client-web.sh — build the Flutter Web app into dist/web.tar.gz. +# Client pipeline (client-v*). Served at /app by nginx. +set -euo pipefail + +# shellcheck source=scripts/ci/_env.sh +. "$(dirname "$0")/_env.sh" + +TAG="$1" +VER="${TAG#client-v}" + +echo "==> compile-client-web: version=${VER}" + +# Sync Flutter pubspec version (BSD sed on macOS) +sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml + +cd client +flutter build web --release \ + --base-href=/app/ \ + "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ + "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ + "--dart-define=APP_VERSION=v${VER}" +cd .. + +mkdir -p dist +tar -czf dist/web.tar.gz -C client/build web + +echo "==> compile-client-web: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/compile-ios.sh b/scripts/ci/compile-ios.sh index 1d4f104..bb18f27 100755 --- a/scripts/ci/compile-ios.sh +++ b/scripts/ci/compile-ios.sh @@ -17,7 +17,7 @@ set -euo pipefail . "$(dirname "$0")/_env.sh" TAG="$1" -VER="${TAG#v}" +VER="${TAG#client-v}" # CFBundleVersion(build 号)必须单调递增,否则 TestFlight 拒绝重复上传。 MAJOR="$(echo "$VER" | cut -d. -f1)" @@ -105,7 +105,7 @@ flutter build ipa --release \ --export-options-plist="$WORK/ExportOptions.plist" \ "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ - "--dart-define=APP_VERSION=${TAG}" + "--dart-define=APP_VERSION=v${VER}" cd .. IPA="$(ls client/build/ios/ipa/*.ipa | head -1)" diff --git a/scripts/ci/compile-macos.sh b/scripts/ci/compile-macos.sh index 0441743..14c4b4a 100755 --- a/scripts/ci/compile-macos.sh +++ b/scripts/ci/compile-macos.sh @@ -6,7 +6,7 @@ set -euo pipefail . "$(dirname "$0")/_env.sh" TAG="$1" -VER="${TAG#v}" +VER="${TAG#client-v}" echo "==> compile-macos: version=${VER}" @@ -18,7 +18,7 @@ cd client flutter build macos --release \ "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ - "--dart-define=APP_VERSION=${TAG}" + "--dart-define=APP_VERSION=v${VER}" cd .. # Package .app bundle into zip diff --git a/scripts/ci/compile-site.sh b/scripts/ci/compile-site.sh new file mode 100644 index 0000000..b041953 --- /dev/null +++ b/scripts/ci/compile-site.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# compile-site.sh — build the Eleventy marketing site into +# dist/marketing.tar.gz. Site pipeline (site-v*). Marketing pages only — +# the web version of the app is built by compile-client-web.sh. +set -euo pipefail + +# shellcheck source=scripts/ci/_env.sh +. "$(dirname "$0")/_env.sh" + +TAG="$1" +echo "==> compile-site: tag=${TAG}" + +cd web +npm ci --prefer-offline +npm run build +cd .. + +mkdir -p dist +tar -czf dist/marketing.tar.gz -C web/dist . + +echo "==> compile-site: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/compile-windows.sh b/scripts/ci/compile-windows.sh index e450c68..f04f5dc 100755 --- a/scripts/ci/compile-windows.sh +++ b/scripts/ci/compile-windows.sh @@ -6,7 +6,7 @@ set -euo pipefail . "$(dirname "$0")/_env.sh" TAG="$1" -VER="${TAG#v}" +VER="${TAG#client-v}" echo "==> compile-windows: version=${VER}" @@ -41,7 +41,7 @@ flutter create --platforms=windows . --project-name jiu_client flutter build windows --release \ "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ - "--dart-define=APP_VERSION=${TAG}" + "--dart-define=APP_VERSION=v${VER}" popd > /dev/null # Package the Release folder into a Windows installer with Inno Setup (ISCC). diff --git a/scripts/ci/compile.sh b/scripts/ci/compile.sh deleted file mode 100755 index 8355884..0000000 --- a/scripts/ci/compile.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# compile.sh — build backend + Flutter web, package into dist/ -set -euo pipefail - -# shellcheck source=scripts/ci/_env.sh -. "$(dirname "$0")/_env.sh" - -TAG="$1" -VER="${TAG#v}" - -echo "==> compile: version=$VER" - -# Sync Flutter pubspec version -sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml - -# Build Go backend (linux/amd64 for EC2) -cd backend -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server . -cd .. - -# Build Flutter Web -cd client -flutter build web --release \ - --base-href=/app/ \ - "--dart-define=BASE_URL=https://jiu.51yanmei.com" \ - "--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \ - "--dart-define=APP_VERSION=${TAG}" -cd .. - -# Build marketing site -cd web -npm ci --prefer-offline -npm run build -cd .. - -# Package artifacts -mkdir -p dist -mv backend/jiu-server dist/jiu-server -tar -czf dist/web.tar.gz -C client/build web -tar -czf dist/marketing.tar.gz -C web/dist . - -echo "==> compile: done — dist/ contents:" -ls -lh dist/ diff --git a/scripts/ci/deploy-client.sh b/scripts/ci/deploy-client.sh new file mode 100644 index 0000000..ae675b3 --- /dev/null +++ b/scripts/ci/deploy-client.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# deploy-client.sh — deploy the Flutter web app, desktop/mobile installers, +# and version.yaml to EC2. Does NOT restart the backend (version.yaml is read +# per-request) nor touch nginx / the marketing site. +set -euo pipefail + +# shellcheck source=scripts/ci/lib-forgejo.sh +. "$(dirname "$0")/lib-forgejo.sh" + +TAG="$1" +echo "==> deploy-client: tag=${TAG}" + +if [ ! -f dist/web.tar.gz ] || [ ! -f dist/version.yaml ]; then + download_release_assets "$TAG" +fi + +echo "==> deploy-client: dist/ contents:" +ls -lh dist/ + +# Prefer the version.yaml released for this tag (carries bumped build_number + +# changelog); fall back to the checkout copy only if the asset is absent. +VERSION_YAML="dist/version.yaml" +[ -f "$VERSION_YAML" ] || VERSION_YAML="backend/config/version.yaml" + +rm -rf /tmp/jiu-web-new +mkdir -p /tmp/jiu-web-new +tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1 + +setup_ssh +echo "==> deploy-client: uploading files to EC2" +${SCP} "$VERSION_YAML" "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml" +rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ + /tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/" + +# Desktop / mobile installers (served from /downloads/ by nginx). Guarded — +# may be absent in a partial manual deploy. +[ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true +[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true +[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true + +${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' +set -e + +# Update version config (WorkingDirectory=/opt/jiu reads config/version.yaml). +# Backend re-reads it per request — no restart needed. +mkdir -p /opt/jiu/config +cp /tmp/version.yaml /opt/jiu/config/version.yaml + +# Swap Flutter web app (atomic) +rm -rf /opt/jiu/web-old +mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true +mv /tmp/jiu-web-new /opt/jiu/web + +# Publish installers to the nginx-served downloads dir; keep only the latest. +mkdir -p /opt/jiu/downloads +rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/downloads/jiu-android-* /opt/jiu/downloads/jiu-android.apk 2>/dev/null || true +[ -f /tmp/jiu-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || true +[ -f /tmp/jiu-macos-x64.zip ] && mv -f /tmp/jiu-macos-x64.zip /opt/jiu/downloads/ || true +[ -f /tmp/jiu-android.apk ] && mv -f /tmp/jiu-android.apk /opt/jiu/downloads/ || true + +echo "Client deploy complete!" +ENDSSH + +teardown_ssh +rm -rf /tmp/jiu-web-new +echo "==> deploy-client: done" diff --git a/scripts/ci/deploy-server.sh b/scripts/ci/deploy-server.sh new file mode 100644 index 0000000..3567e8a --- /dev/null +++ b/scripts/ci/deploy-server.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# deploy-server.sh — deploy the backend binary + nginx config to EC2. +# Two modes: uses dist/ built in the same job, or downloads the release assets +# by tag (manual rollback). Does NOT touch the web app, marketing site, or +# version.yaml. +set -euo pipefail + +# shellcheck source=scripts/ci/lib-forgejo.sh +. "$(dirname "$0")/lib-forgejo.sh" + +TAG="$1" +echo "==> deploy-server: tag=${TAG}" + +if [ ! -f dist/jiu-server ] || [ ! -f dist/configs.tar.gz ]; then + download_release_assets "$TAG" +fi + +echo "==> deploy-server: dist/ contents:" +ls -lh dist/ + +rm -rf /tmp/jiu-configs +mkdir -p /tmp/jiu-configs +tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs + +setup_ssh +echo "==> deploy-server: uploading files to EC2" +${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server" +${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf" + +${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' +set -e + +# Replace backend binary +sudo systemctl stop jiu +cp /tmp/jiu-server /opt/jiu/backend/jiu-server +chmod +x /opt/jiu/backend/jiu-server + +# Start and health check +sudo systemctl start jiu +echo "Waiting for health check..." +for i in $(seq 1 30); do + if curl -f http://localhost:8080/health > /dev/null 2>&1; then + echo "Health check passed" + break + fi + sleep 2 +done +curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; } + +# Update jiu nginx config in the pangolin-edge reverse proxy (host-bind-mounted +# conf.d; reload nginx inside the container). +cp /tmp/nginx-jiu.conf /home/ec2-user/pangolin/edge/conf.d/jiu.conf +docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload + +echo "Server deploy complete!" +ENDSSH + +teardown_ssh +rm -rf /tmp/jiu-configs +echo "==> deploy-server: done" diff --git a/scripts/ci/deploy-site.sh b/scripts/ci/deploy-site.sh new file mode 100644 index 0000000..0db12b5 --- /dev/null +++ b/scripts/ci/deploy-site.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# deploy-site.sh — deploy the Eleventy marketing site to EC2 +# (/opt/jiu/marketing). Does NOT restart the backend or touch nginx. +set -euo pipefail + +# shellcheck source=scripts/ci/lib-forgejo.sh +. "$(dirname "$0")/lib-forgejo.sh" + +TAG="$1" +echo "==> deploy-site: tag=${TAG}" + +if [ ! -f dist/marketing.tar.gz ]; then + download_release_assets "$TAG" +fi + +echo "==> deploy-site: dist/ contents:" +ls -lh dist/ + +rm -rf /tmp/jiu-marketing-new +mkdir -p /tmp/jiu-marketing-new +tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new + +setup_ssh +echo "==> deploy-site: uploading marketing site to EC2" +rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ + /tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/" + +${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' +set -e +mkdir -p /opt/jiu/marketing +rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/ +rm -rf /tmp/jiu-marketing-new +echo "Site deploy complete!" +ENDSSH + +teardown_ssh +rm -rf /tmp/jiu-marketing-new +echo "==> deploy-site: done" diff --git a/scripts/ci/deploy.sh b/scripts/ci/deploy.sh deleted file mode 100755 index c1b316c..0000000 --- a/scripts/ci/deploy.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# deploy.sh — deploy release to EC2 -# Works in two modes: -# 1. Automated (after compile.sh): uses dist/ built in the same job -# 2. Manual/rollback: downloads assets from Forgejo Release by tag -set -euo pipefail - -TAG="$1" - -echo "==> deploy: tag=${TAG}" - -# Download from Forgejo if dist/ was not built in this job -if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ] || [ ! -f dist/marketing.tar.gz ]; then - echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo" - mkdir -p dist - - curl -kf \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \ - -o /tmp/release-info.json - - python3 - <<'PYEOF' -import json, urllib.request, ssl, os, sys - -ctx = ssl.create_default_context() -ctx.check_hostname = False -ctx.verify_mode = ssl.CERT_NONE - -with open('/tmp/release-info.json') as f: - release = json.load(f) - -token = os.environ['FORGEJO_TOKEN'] -forgejo_url = os.environ['FORGEJO_URL'] -repo = os.environ['GITEA_REPOSITORY'] -release_id = release['id'] - -for asset in release.get('assets', []): - url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}" - req = urllib.request.Request(url, headers={'Authorization': f'token {token}'}) - with urllib.request.urlopen(req, context=ctx) as resp: - with open(f"dist/{asset['name']}", 'wb') as out: - out.write(resp.read()) - print(f"Downloaded: {asset['name']}") -PYEOF -fi - -echo "==> deploy: dist/ contents:" -ls -lh dist/ - -# Extract configs (nginx, version.yaml, etc.) -rm -rf /tmp/jiu-configs -mkdir -p /tmp/jiu-configs -tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs - -# Extract Flutter web build -rm -rf /tmp/jiu-web-new -mkdir -p /tmp/jiu-web-new -tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1 - -# Extract marketing site -rm -rf /tmp/jiu-marketing-new -mkdir -p /tmp/jiu-marketing-new -tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new - -# Setup SSH -mkdir -p ~/.ssh -printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem -chmod 600 ~/.ssh/ec2_deploy.pem -ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts - -SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" -SCP="scp -O -i ~/.ssh/ec2_deploy.pem" - -echo "==> deploy: uploading files to EC2" - -# Upload backend binary -${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server" - -# Upload version.yaml -${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml" - -# Upload nginx config -${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf" - -# Upload Flutter web -rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ - /tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/" - -# Upload marketing site (built by compile.sh) -rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ - /tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/" - -# Upload desktop client installers (served from /downloads/ by nginx). -# Guarded: may be absent in a partial manual deploy. -[ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true -[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true -[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true - -echo "==> deploy: running remote deploy" - -${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' -set -e - -# Replace backend binary -sudo systemctl stop jiu -cp /tmp/jiu-server /opt/jiu/backend/jiu-server -chmod +x /opt/jiu/backend/jiu-server - -# Update version config(WorkingDirectory=/opt/jiu,读 config/version.yaml) -mkdir -p /opt/jiu/config -cp /tmp/version.yaml /opt/jiu/config/version.yaml - -# Start and health check -sudo systemctl start jiu -echo "Waiting for health check..." -for i in $(seq 1 30); do - if curl -f http://localhost:8080/health > /dev/null 2>&1; then - echo "Health check passed" - break - fi - sleep 2 -done -curl -f http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; } - -# Swap Flutter web app (atomic) -rm -rf /opt/jiu/web-old -mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true -mv /tmp/jiu-web-new /opt/jiu/web - -# Update marketing site -mkdir -p /opt/jiu/marketing -rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/ -rm -rf /tmp/jiu-marketing-new - -# Publish desktop client installers to the nginx-served downloads dir. -# Keep only the latest version: clear old installers, then drop in the new ones. -mkdir -p /opt/jiu/downloads -rm -f /opt/jiu/downloads/jiu-windows-* /opt/jiu/downloads/jiu-macos-* /opt/jiu/downloads/jiu-android-* /opt/jiu/downloads/jiu-android.apk 2>/dev/null || true -[ -f /tmp/jiu-windows-x64-setup.exe ] && mv -f /tmp/jiu-windows-x64-setup.exe /opt/jiu/downloads/ || true -[ -f /tmp/jiu-macos-x64.zip ] && mv -f /tmp/jiu-macos-x64.zip /opt/jiu/downloads/ || true -[ -f /tmp/jiu-android.apk ] && mv -f /tmp/jiu-android.apk /opt/jiu/downloads/ || true - -# Update jiu nginx config in the pangolin-edge reverse proxy. -# nginx now runs inside the `pangolin-edge` container (host networking), not on -# the host. Its /etc/nginx/conf.d is bind-mounted from the host dir below, so we -# write the config there and reload nginx *inside the container*. -cp /tmp/nginx-jiu.conf /home/ec2-user/pangolin/edge/conf.d/jiu.conf -docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload - -echo "Deploy complete!" -ENDSSH - -# Cleanup SSH key -rm -f ~/.ssh/ec2_deploy.pem -rm -rf /tmp/jiu-configs /tmp/jiu-web-new - -echo "==> deploy: done" diff --git a/scripts/ci/lib-forgejo.sh b/scripts/ci/lib-forgejo.sh new file mode 100644 index 0000000..ad9fe6e --- /dev/null +++ b/scripts/ci/lib-forgejo.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# lib-forgejo.sh — shared CI helpers for the three release pipelines +# (client / site / server). `source` this from release-*.sh and deploy-*.sh. +# +# Provides: +# ver_from_tag -> strips client-v|site-v|server-v|v prefix +# extract_release_notes -> prints the latest "## [x]" section body +# create_release -> POSTs a Forgejo Release, sets RELEASE_ID +# upload_asset -> uploads one asset (needs RELEASE_ID) +# download_release_assets -> downloads all assets of a release into dist/ +# setup_ssh / teardown_ssh -> prepare/clean the EC2 deploy key (sets SSH/SCP) +# +# Requires env (where relevant): FORGEJO_URL, FORGEJO_TOKEN, GITEA_REPOSITORY, +# EC2_SSH_KEY, EC2_HOST, EC2_USER. + +# Strip the part-specific tag prefix, leaving a bare semver (1.2.3). +ver_from_tag() { + local tag="$1" + tag="${tag#client-v}" + tag="${tag#site-v}" + tag="${tag#server-v}" + tag="${tag#v}" + printf '%s' "$tag" +} + +# Print the body (lines) of the most recent "## [ver] - date" section. +extract_release_notes() { + python3 - "$1" <<'PYEOF' +import re, sys +fn = sys.argv[1] +try: + with open(fn, encoding='utf-8') as f: + content = f.read() + parts = re.split(r'\n## ', '\n' + content) + if len(parts) > 1: + lines = parts[1].strip().split('\n') + notes = [] + for line in lines[1:]: + s = line.strip() + if s.startswith('## '): + break + if s: + notes.append(s) + print('\n'.join(notes) if notes else lines[0]) + else: + print('') +except Exception: + print('') +PYEOF +} + +# create_release — sets global RELEASE_ID on success. +create_release() { + local tag="$1" body="$2" http_code resp + echo "==> release: creating Forgejo Release ${tag}" + http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \ + -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${tag}\",\"name\":\"Release ${tag}\",\"body\":${body},\"draft\":false,\"prerelease\":false}") + resp=$(cat /tmp/release_resp.json) + echo "==> release: API response (HTTP ${http_code}): ${resp}" + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "==> release: FAILED — HTTP ${http_code}" >&2 + return 1 + fi + RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${resp}") + echo "==> release: release_id=${RELEASE_ID}" +} + +# upload_asset — requires RELEASE_ID. +upload_asset() { + local file="$1" code + code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \ + -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -F "attachment=@${file}") + echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)" + if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then return 1; fi +} + +# download_release_assets — pull every asset of the release into dist/. +download_release_assets() { + local tag="$1" + echo "==> deploy: downloading assets for ${tag} from Forgejo" + mkdir -p dist + curl -kf -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}" \ + -o /tmp/release-info.json + python3 - <<'PYEOF' +import json, urllib.request, ssl, os +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE +rel = json.load(open('/tmp/release-info.json')) +token = os.environ['FORGEJO_TOKEN'] +url = os.environ['FORGEJO_URL'] +repo = os.environ['GITEA_REPOSITORY'] +rid = rel['id'] +for a in rel.get('assets', []): + u = f"{url}/api/v1/repos/{repo}/releases/{rid}/assets/{a['id']}" + req = urllib.request.Request(u, headers={'Authorization': f'token {token}'}) + with urllib.request.urlopen(req, context=ctx) as r, open(f"dist/{a['name']}", 'wb') as o: + o.write(r.read()) + print('Downloaded:', a['name']) +PYEOF +} + +# setup_ssh — write the EC2 deploy key and export SSH / SCP commands. +setup_ssh() { + mkdir -p ~/.ssh + printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem + chmod 600 ~/.ssh/ec2_deploy.pem + ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" + SCP="scp -O -i ~/.ssh/ec2_deploy.pem" +} + +teardown_ssh() { + rm -f ~/.ssh/ec2_deploy.pem +} diff --git a/scripts/ci/release-client.sh b/scripts/ci/release-client.sh new file mode 100644 index 0000000..e81eeaf --- /dev/null +++ b/scripts/ci/release-client.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# release-client.sh — client pipeline release. +# 1. Update backend/config/version.yaml: version / build_number / release_notes +# / macos+windows+android download URLs / changelog[] (latest 3 from +# CHANGELOG-client.md). +# 2. Create the Forgejo Release client-vX and upload the app artifacts + +# version.yaml (so manual rollback can re-fetch the exact manifest). +# +# version.yaml is client-owned: the backend reads it per-request, so deploying +# it does NOT require a backend restart or a server-pipeline run. +set -euo pipefail + +# shellcheck source=scripts/ci/lib-forgejo.sh +. "$(dirname "$0")/lib-forgejo.sh" + +TAG="$1" +VER="$(ver_from_tag "$TAG")" +echo "==> release-client: tag=${TAG} version=${VER}" + +NOTES=$(extract_release_notes CHANGELOG-client.md) +echo "==> release-client: release_notes=${NOTES}" + +# --- Update version.yaml (scalars + download URLs) and append changelog[] --- +python3 - "$VER" "$NOTES" "CHANGELOG-client.md" <<'PYEOF' +import sys, re, json + +ver = sys.argv[1] +notes = sys.argv[2] +changelog_file = sys.argv[3] + +base = "https://jiu.51yanmei.com/downloads" +mac_url = f"{base}/jiu-macos-x64.zip" +win_url = f"{base}/jiu-windows-x64-setup.exe" +android_url = f"{base}/jiu-android.apk" + +# Read current version.yaml, dropping any existing trailing `changelog:` block +# (changelog is always emitted last, so everything from it to EOF is regenerated). +with open('backend/config/version.yaml', encoding='utf-8') as f: + raw = f.read() +raw = re.split(r'\nchangelog:', raw, maxsplit=1)[0].rstrip('\n') + '\n' + +out = [] +for line in raw.splitlines(keepends=True): + if line.startswith('version:'): + out.append('version: "%s"\n' % ver) + elif line.startswith('build_number:'): + try: + b = int(line.split(':', 1)[1].strip()) + 1 + except Exception: + b = 1 + out.append('build_number: %d\n' % b) + elif line.startswith('release_notes:'): + safe = notes.replace('\\', '\\\\').replace('"', '\\"').replace('\n', ';') + out.append('release_notes: "%s"\n' % safe) + elif line.startswith(' macos:'): + out.append(' macos: "%s"\n' % mac_url) + elif line.startswith(' windows:'): + out.append(' windows: "%s"\n' % win_url) + elif line.startswith(' android:'): + out.append(' android: "%s"\n' % android_url) + else: + out.append(line) + +# Parse CHANGELOG-client.md -> latest 3 entries (same shape as web/_data/changelog.js) +versions = [] +cur = None +sec = None +for line in open(changelog_file, encoding='utf-8'): + line = line.rstrip('\n') + m = re.match(r'^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})', line) + if m: + if cur: + versions.append(cur) + if len(versions) >= 3: + cur = None + break + cur = {'version': m.group(1), 'date': m.group(2), 'intro': '', 'sections': []} + sec = None + continue + ms = re.match(r'^### (.+)', line) + if ms and cur is not None: + sec = {'type': ms.group(1).strip(), 'items': []} + cur['sections'].append(sec) + continue + mi = re.match(r'^- (.+)', line) + if mi and cur is not None and sec is not None: + sec['items'].append(mi.group(1).strip()) + continue + if line.strip() and cur is not None and sec is None and not line.startswith('#'): + cur['intro'] = (cur['intro'] + ' ' + line.strip()).strip() +if cur is not None and len(versions) < 3: + versions.append(cur) + +# Append changelog as a JSON array (valid YAML flow style — no pyyaml needed). +text = ''.join(out).rstrip('\n') + '\n' +text += 'changelog: ' + json.dumps(versions, ensure_ascii=False) + '\n' +with open('backend/config/version.yaml', 'w', encoding='utf-8') as f: + f.write(text) +print('version.yaml updated to', ver, '|', len(versions), 'changelog entries') +PYEOF + +# Stage version.yaml as a release asset so manual rollback can re-fetch it. +mkdir -p dist +cp backend/config/version.yaml dist/version.yaml + +BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF' +import sys, json +print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2])) +PYEOF +) + +create_release "$TAG" "$BODY" +upload_asset dist/web.tar.gz +upload_asset dist/version.yaml +[ -f dist/jiu-macos-x64.zip ] && upload_asset dist/jiu-macos-x64.zip || true +[ -f dist/jiu-windows-x64-setup.exe ] && upload_asset dist/jiu-windows-x64-setup.exe || true +[ -f dist/jiu-android.apk ] && upload_asset dist/jiu-android.apk || true + +echo "==> release-client: done — Release ${TAG} created" diff --git a/scripts/ci/release-server.sh b/scripts/ci/release-server.sh new file mode 100644 index 0000000..3553484 --- /dev/null +++ b/scripts/ci/release-server.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# release-server.sh — create the Forgejo Release for the backend pipeline +# and upload jiu-server + configs.tar.gz. +set -euo pipefail + +# shellcheck source=scripts/ci/lib-forgejo.sh +. "$(dirname "$0")/lib-forgejo.sh" + +TAG="$1" +echo "==> release-server: tag=${TAG}" + +NOTES=$(extract_release_notes CHANGELOG-server.md) +echo "==> release-server: release_notes=${NOTES}" + +BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF' +import sys, json +print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2])) +PYEOF +) + +create_release "$TAG" "$BODY" +upload_asset dist/jiu-server +upload_asset dist/configs.tar.gz + +echo "==> release-server: done — Release ${TAG} created" diff --git a/scripts/ci/release-site.sh b/scripts/ci/release-site.sh new file mode 100644 index 0000000..37071ea --- /dev/null +++ b/scripts/ci/release-site.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# release-site.sh — create the Forgejo Release for the marketing-site +# pipeline and upload marketing.tar.gz. +set -euo pipefail + +# shellcheck source=scripts/ci/lib-forgejo.sh +. "$(dirname "$0")/lib-forgejo.sh" + +TAG="$1" +echo "==> release-site: tag=${TAG}" + +NOTES=$(extract_release_notes CHANGELOG-site.md) +echo "==> release-site: release_notes=${NOTES}" + +BODY=$(python3 - "$TAG" "$NOTES" <<'PYEOF' +import sys, json +print(json.dumps('## ' + sys.argv[1] + '\n\n' + sys.argv[2])) +PYEOF +) + +create_release "$TAG" "$BODY" +upload_asset dist/marketing.tar.gz + +echo "==> release-site: done — Release ${TAG} created" diff --git a/scripts/ci/release.sh b/scripts/ci/release.sh deleted file mode 100755 index 16cd5c7..0000000 --- a/scripts/ci/release.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# release.sh — update version.yaml, package configs, create Forgejo Release -set -euo pipefail - -TAG="$1" -VER="${TAG#v}" - -echo "==> release: tag=${TAG} version=${VER}" - -# Extract release notes from CHANGELOG.md (first summary line of the latest section) -RELEASE_NOTES=$(python3 - <<'PYEOF' -import re, sys -try: - with open('CHANGELOG.md') as f: - content = f.read() - parts = re.split(r'\n## ', '\n' + content) - if len(parts) > 1: - section = parts[1] - lines = section.strip().split('\n') - notes = [] - for line in lines[1:]: - stripped = line.strip() - # 只在遇到同级 ## 时停止,### 子标题保留 - if stripped.startswith('## '): - break - if stripped: - notes.append(stripped) - print('\n'.join(notes) if notes else lines[0]) - else: - print('') -except Exception as e: - print('', file=sys.stderr) - print('') -PYEOF -) - -echo "==> release: release_notes=${RELEASE_NOTES}" - -# Update backend/config/version.yaml (version, build_number, release_notes, macos/windows URL) -python3 - "${VER}" "${RELEASE_NOTES}" "${TAG}" "${FORGEJO_URL}" "${GITEA_REPOSITORY}" <<'PYEOF' -import sys -ver = sys.argv[1] -notes = sys.argv[2] -tag = sys.argv[3] -furl = sys.argv[4].rstrip('/') -repo = sys.argv[5] -# Public download URLs are served by jiu.51yanmei.com (same-origin HTTPS via the -# pangolin nginx /downloads/ location), NOT the LAN-only HTTP Forgejo instance — -# otherwise the HTTPS download page hands the browser an http://192.168.x URL and -# Chrome blocks it as insecure / it's unreachable from the public internet. -base = "https://jiu.51yanmei.com/downloads" -mac_url = f"{base}/jiu-macos-x64.zip" -win_url = f"{base}/jiu-windows-x64-setup.exe" -android_url = f"{base}/jiu-android.apk" - -lines = [] -with open('backend/config/version.yaml') as f: - for line in f: - if line.startswith('version:'): - lines.append('version: "' + ver + '"\n') - elif line.startswith('build_number:'): - try: - build = int(line.split(':', 1)[1].strip()) + 1 - except Exception: - build = 1 - lines.append('build_number: ' + str(build) + '\n') - elif line.startswith('release_notes:'): - lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n') - elif line.startswith(' macos:'): - lines.append(' macos: "' + mac_url + '"\n') - elif line.startswith(' windows:'): - lines.append(' windows: "' + win_url + '"\n') - elif line.startswith(' android:'): - lines.append(' android: "' + android_url + '"\n') - else: - lines.append(line) -with open('backend/config/version.yaml', 'w') as f: - f.writelines(lines) -print('version.yaml updated to', ver, '| macos:', mac_url, '| windows:', win_url, '| android:', android_url) -PYEOF - -# Package configs.tar.gz (includes updated version.yaml) -tar -czf dist/configs.tar.gz \ - deploy/nginx-jiu.conf \ - deploy/jiu.service \ - deploy/production.env.template \ - deploy/setup-ec2.sh \ - deploy/docker-compose.yml \ - deploy/docker-compose.jiu.yml \ - backend/config/version.yaml - -echo "==> release: creating Forgejo Release ${TAG}" - -# Build release body from CHANGELOG excerpt -BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF' -import sys, json -tag = sys.argv[1] -notes = sys.argv[2] -body = '## ' + tag + '\n\n' + notes -print(json.dumps(body)) -PYEOF -) - -HTTP_CODE=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \ - -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}") - -RESP=$(cat /tmp/release_resp.json) -echo "==> release: API response (HTTP ${HTTP_CODE}): ${RESP}" - -if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then - echo "==> release: FAILED — HTTP ${HTTP_CODE}" - exit 1 -fi - -RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}") -echo "==> release: release_id=${RELEASE_ID}" - -# Upload assets -upload_asset() { - local file="$1" - local code - code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \ - -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -F "attachment=@${file}") - echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)" - if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then exit 1; fi -} - -upload_asset dist/jiu-server -upload_asset dist/web.tar.gz -upload_asset dist/marketing.tar.gz -upload_asset dist/configs.tar.gz -upload_asset dist/jiu-macos-x64.zip -upload_asset dist/jiu-windows-x64-setup.exe -upload_asset dist/jiu-android.apk - -echo "==> release: done — Release ${TAG} created" diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh index b38150c..9c87e34 100755 --- a/scripts/ci/test.sh +++ b/scripts/ci/test.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash -# test.sh — run backend and frontend checks +# test.sh [part] — run gate checks for a pipeline part. +# server -> go test +# client -> flutter analyze +# (none) -> both (backwards compatible) set -euo pipefail export PATH="/opt/homebrew/bin:$PATH" @@ -7,14 +10,28 @@ export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}" export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}" -echo "==> test: go test" -cd backend -go test ./... -cd .. +PART="${1:-all}" -echo "==> test: flutter analyze" -cd client -flutter analyze --no-fatal-infos --no-fatal-warnings -cd .. +run_server() { + echo "==> test: go test" + cd backend + go test ./... + cd .. +} + +run_client() { + echo "==> test: flutter analyze" + cd client + flutter analyze --no-fatal-infos --no-fatal-warnings + cd .. +} + +case "$PART" in + server) run_server ;; + client) run_client ;; + site) echo "==> test: site has no test gate (build is the gate)" ;; + all) run_server; run_client ;; + *) echo "unknown part: $PART" >&2; exit 1 ;; +esac echo "==> test: done" diff --git a/web/_data/changelog.js b/web/_data/changelog.js index 8730072..7dede7d 100644 --- a/web/_data/changelog.js +++ b/web/_data/changelog.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); module.exports = function() { - const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG.md'), 'utf8'); + const raw = fs.readFileSync(path.join(__dirname, '../../CHANGELOG-client.md'), 'utf8'); const lines = raw.split('\n'); const versions = []; let current = null; diff --git a/web/dist/docs/index.html b/web/dist/docs/index.html index 6e5c950..b0441a9 100644 --- a/web/dist/docs/index.html +++ b/web/dist/docs/index.html @@ -72,7 +72,7 @@
版本 - v1.0.23 + v1.0.54
@@ -840,6 +840,7 @@ 岩美

为酒行与酒店设计的库存、审核、财务一体化管理平台。

@@ -872,14 +873,12 @@
公司
diff --git a/web/dist/download/index.html b/web/dist/download/index.html index 669e8f4..148bea3 100644 --- a/web/dist/download/index.html +++ b/web/dist/download/index.html @@ -308,7 +308,7 @@
- v1.0.23 + v1.0.54 下载岩美

随时随地,
轻松管理酒库。

@@ -318,11 +318,11 @@
最新版本
-
v1.0.23
+
v1.0.54
发布日期
-
2026-06-07
+
2026-06-17
可用平台
@@ -545,14 +545,14 @@

版本更新日志

-

最近 3 个版本。

+

最近 3 个版本。

-
+
-
v1.0.23
-
2026-06-07
+
v1.0.54
+
2026-06-17
当前版本
@@ -561,12 +561,16 @@
- 问题修复 + 新功能
    -
  • 官网功能详情页(库存管理/审核流)导航栏现与主站完全一致,不再缺少「库存」「审核流」入口
  • +
  • 只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然
  • + +
  • 左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出
  • + +
  • 酒行信息新增「微信号」显示
@@ -579,47 +583,9 @@
    -
  • 官网功能详情页接入统一模板系统,导航/页脚/版权信息改一处自动同步全站
  • +
  • 网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮
  • -
-
- -
-
- -
-
-
v1.0.22
-
2026-06-07
- -
-
- - -
-
- - 新功能 - -
-
    - -
  • 官网移动端适配:手机浏览器下导航折叠、首页各区块单列排版、features 详情页和页脚自适应窄屏
  • - -
-
- -
-
- - 改进 - -
-
    - -
  • 客户端移动端布局优化:入库/出库详情页商品明细改为卡片式竖排,操作按钮收纳至溢出菜单,窄屏不再横向溢出
  • - -
  • 信息架构整理:仓库管理移入基础数据,系统设置新增授权 Tab 和数据管理组,关于我们补充帮助文档/法律信息/系统信息入口
  • +
  • iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡
@@ -632,17 +598,9 @@
    -
  • 官网所有功能页死链修正(`/download.html` → `/download/`,`/docs.html` → `/docs/`)
  • +
  • 库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖
  • -
  • 官网删除未实现功能的虚假宣传(红冲、调拨单、库存推送通知、过期预警等),文案与实际功能对齐
  • - -
  • 官网客服邮箱更新为真实邮箱,移除假冒客服电话,页脚清除无目标死链
  • - -
  • 导航新增「库存」「审核流」功能页入口,首页模块卡加「了解更多」链接
  • - -
  • 下载页接口失败时按钮改为禁用态并提示,不再无响应
  • - -
  • 首页 iOS 平台说明改为「TestFlight 内测中」,与下载页状态一致
  • +
  • 修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题
@@ -652,8 +610,8 @@
-
v1.0.21
-
2026-06-07
+
v1.0.53
+
2026-06-15
@@ -662,12 +620,37 @@
- 新功能 + 问题修复
    -
  • 商品详情页添加照片时,手机端(Android/iOS)支持直接拍照或从相册选择
  • +
  • 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
  • + +
+
+ +
+
+ +
+
+
v1.0.52
+
2026-06-15
+ +
+
+ + +
+
+ + 改进 + +
+
    + +
  • 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
@@ -747,6 +730,46 @@ }); } + // 用 /api/v1/public/release 返回的 changelog 重渲染更新日志时间线,使客户端 + // 发版无需重建官网即可同步。结构与构建时 web/_data/changelog.js 一致;接口 + // 不可达或字段缺失时保留构建时静态时间线作 fallback。 + function renderChangelog(entries) { + if (!Array.isArray(entries) || entries.length === 0) return; + var dotClass = { '新功能': 'dot-feature', '改进': 'dot-improve', '修复': 'dot-fix' }; + var label = { '修复': '问题修复' }; + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"]/g, function(c) { + return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; + }); + } + var html = ''; + entries.forEach(function(entry, i) { + var current = i === 0; + html += '
'; + html += '
'; + html += '
v' + esc(entry.version) + '
'; + html += '
' + esc(entry.date) + '
'; + if (current) html += '当前版本'; + html += '
'; + if (entry.intro) html += '

' + esc(entry.intro) + '

'; + (entry.sections || []).forEach(function(section) { + var dot = dotClass[section.type] || 'dot-improve'; + var name = label[section.type] || section.type; + html += '
'; + html += '' + esc(name) + '
    '; + (section.items || []).forEach(function(item) { + html += '
  • ' + esc(item) + '
  • '; + }); + html += '
'; + }); + html += '
'; + }); + var list = document.getElementById('changelog-list'); + if (list) list.innerHTML = html; + var count = document.getElementById('changelog-count'); + if (count) count.textContent = '最近 ' + entries.length + ' 个版本。'; + } + document.addEventListener('DOMContentLoaded', function() { var detected = detectPlatform(); updateDetectCard(detected); @@ -756,6 +779,7 @@ .then(function(r) { return r.json(); }) .then(function(data) { if (data && data.version) updateVersionBadges(data.version); + if (data && data.changelog) renderChangelog(data.changelog); if (data && data.download_urls && data.download_urls.macos) { platforms.macos.btnHref = data.download_urls.macos; var macBtn = document.getElementById('macos-dl-btn'); @@ -822,6 +846,7 @@ 岩美

为酒行与酒店设计的库存、审核、财务一体化管理平台。

@@ -854,14 +879,12 @@
公司
diff --git a/web/dist/features/approval.html b/web/dist/features/approval.html index e10e359..736edbe 100644 --- a/web/dist/features/approval.html +++ b/web/dist/features/approval.html @@ -670,6 +670,7 @@ 岩美

为酒行与酒店设计的库存、审核、财务一体化管理平台。

@@ -702,14 +703,12 @@
公司
diff --git a/web/dist/features/inventory.html b/web/dist/features/inventory.html index ca457b8..8d49fac 100644 --- a/web/dist/features/inventory.html +++ b/web/dist/features/inventory.html @@ -707,6 +707,7 @@ 岩美

为酒行与酒店设计的库存、审核、财务一体化管理平台。

@@ -739,14 +740,12 @@
公司
diff --git a/web/dist/index.html b/web/dist/index.html index 81be373..d0301cf 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -240,8 +240,8 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
- v1.0.23 - 2026-06-07 更新 · + v1.0.54 + 2026-06-17 更新 · 查看更新

从入库到出库,
一套系统管到底。

@@ -605,7 +605,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
  • 单门店 · 最多 3 用户
  • 全部模块开放
  • Web 端 + 移动端
  • -
  • 邮件技术支持
  • +
  • 微信技术支持
  • 免费开通
    @@ -703,6 +703,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); } 岩美

    为酒行与酒店设计的库存、审核、财务一体化管理平台。

    @@ -715,7 +716,6 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
  • 数据洞察
  • 价格方案
  • 版本更新
  • -
  • 更新日志
  • diff --git a/web/dist/register/index.html b/web/dist/register/index.html index f24f6f9..00ad146 100644 --- a/web/dist/register/index.html +++ b/web/dist/register/index.html @@ -210,7 +210,7 @@ -

    如忘记门店编码,登录后可在「系统设置 → 关于」中查看。

    +

    如忘记门店编码,登录后可在「系统设置」中查看。

    前往登录 @@ -221,7 +221,7 @@