Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ DUALLANE_WEB_BIND=127.0.0.1
DUALLANE_WEB_PORT=8787
# Local/disposable Compose build labels. The guarded production coordinator
# overrides these from the verified release version and full Git commit.
DUALLANE_APP_VERSION=0.18.0
DUALLANE_APP_VERSION=0.19.0
DUALLANE_GIT_COMMIT=development
# Absolute production checkout accepted by deploy/production/deploy.sh. When
# empty, the guarded local default is $HOME/duallane.
Expand Down
4 changes: 2 additions & 2 deletions .github/tests/ci-go-p2p-privacy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import test from "node:test";

test("Go P2P browser gate owns its server and never uploads private failure output", async () => {
const root = new URL("../../", import.meta.url);
const read = (name) => readFile(new URL(name, root), "utf8");
const read = async (name) => (await readFile(new URL(name, root), "utf8")).replace(/\r\n?/g, "\n");
const [workflow, config, nodeConfig, server, manifest] = await Promise.all([
read(".github/workflows/ci.yml"), read("playwright.p2p-go.config.ts"), read("playwright.config.ts"), read("e2e/support/test-server.mjs"), read("package.json")
]);
Expand All @@ -22,7 +22,7 @@ test("Go P2P browser gate owns its server and never uploads private failure outp
assert.doesNotMatch(server, /apps\/web\/server|openTestDatabase|createApp/);
assert.match(server, /go-p2p-server\.mjs/);
assert.match(qualityGate, /- name: Set up Go\n uses: actions\/setup-go@v7\n with:\n go-version-file: apps\/backend\/go\.mod\n cache-dependency-path: apps\/backend\/go\.sum/);
assert.match(qualityGate, /DUALLANE_APP_VERSION: "0\.18\.0"/);
assert.equal(qualityGate.match(/DUALLANE_APP_VERSION: "([^"]+)"/)?.[1], JSON.parse(manifest).version);
assert.match(qualityGate, /docker compose --profile storage-migration build storage-provision/);
assert.match(config, /testMatch:\s*\[\s*"p2p-go-privacy\.spec\.ts",\s*"p2p-ime\.spec\.ts"\s*\]/);
assert.match(config, /outputDir: "\.private-test-results\/p2p-go-browser"/);
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
env:
CI: "true"
TEST_DATABASE_URL: ""
DUALLANE_APP_VERSION: "0.18.0"
DUALLANE_APP_VERSION: "0.19.0"
DUALLANE_GIT_COMMIT: ${{ github.sha }}
POSTGRES_PASSWORD: duallane-ci-password

Expand Down Expand Up @@ -176,7 +176,7 @@ jobs:

- name: Validate production deployment config
env:
DUALLANE_APP_VERSION: "0.18.0"
DUALLANE_APP_VERSION: "0.19.0"
DUALLANE_GIT_COMMIT: ${{ github.sha }}
POSTGRES_VOLUME_NAME: duallane-ci-postgres
POSTGRES_PASSWORD: duallane-ci-password
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ func TestLoadGuideCatalogFromCanonicalSharedAsset(t *testing.T) {
if catalog.Empty() {
t.Fatal("canonical release catalog is empty")
}
guide, ok := catalog.Guide("v0.18.0")
guide, ok := catalog.Guide("v0.19.0")
if !ok {
t.Fatal("canonical catalog does not contain current release 0.18.0")
t.Fatal("canonical catalog does not contain current release 0.19.0")
}
if guide.Version != "0.18.0" || guide.ReleasedAt != "2026-09-10" || guide.Title != "日常功能保持兼容" || guide.Summary != "本次为维护更新,保留聊天、文件、头像和个人设置;沿用原有入口,无需重新设置。" || len(guide.Sections) != 2 || guide.Sections[0].Items[0].Location == "" {
if guide.Version != "0.19.0" || guide.ReleasedAt != "2026-09-11" || guide.Title != "清晰双轨,全新一致体验" || guide.Summary != "统一聊天、话题和个人设置,提供五套主题,让阅读、输入与常用操作更加连贯。" || len(guide.Sections) != 4 || guide.Sections[0].Items[0].Location == "" {
t.Fatalf("incomplete canonical guide: %#v", guide)
}
guide.Sections[0].Items[0].Title = "mutated test copy"
fresh, ok := catalog.Guide("0.18.0")
fresh, ok := catalog.Guide("0.19.0")
if !ok || fresh.Sections[0].Items[0].Title == "mutated test copy" {
t.Fatal("catalog returned mutable internal state")
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@duallane/web",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
35 changes: 35 additions & 0 deletions apps/web/shared/echo-release-guides.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
[
{
"version": "0.19.0",
"releasedAt": "2026-09-11",
"title": "清晰双轨,全新一致体验",
"summary": "统一聊天、话题和个人设置,提供五套主题,让阅读、输入与常用操作更加连贯。",
"sections": [
{
"title": "选择你的外观",
"items": [
{ "title": "五套主题与独立明暗模式", "description": "横向浏览原色、苔原、暮色、米色和极简主题预览,再选择系统、浅色或深色。密度、动态效果和透明效果可以分别调整,外观选择保存在本机。", "location": "个人 -> 外观" }
]
},
{
"title": "连贯的聊天与话题",
"items": [
{ "title": "在话题中使用完整消息", "description": "发送图片、文件和表情,回复消息或使用适用的消息操作。附件后台上传时可以继续发送下一条,失败内容从原消息重试,不覆盖新草稿。", "location": "话题 -> 选择话题 -> 输入消息;消息 -> 更多" },
{ "title": "按需打开详情", "description": "话题的参与、提醒和管理操作收进详情。聊天与话题中栏可以折叠,连续短消息保留连贯表面,阅读历史时保持正在看的位置。", "location": "话题 -> 话题详情;聊天或话题 -> 折叠中栏" }
]
},
{
"title": "更清楚的设置与成员操作",
"items": [
{ "title": "按类别找到个人设置", "description": "资料、外观、聊天、通知、隐私、表情和 Bot 分层展示,手机逐层进入。进入表情分类后,点击管理我的表情再打开工具,关闭后留在原页面。", "location": "个人;个人 -> 表情 -> 管理我的表情" },
{ "title": "搜索并邀请成员", "description": "在邀请窗口搜索并选择多名成员。部分邀请失败时保留未成功项供重试,已经加入的成员不会重复邀请。", "location": "聊天 -> 群聊 -> 会话详情 -> 邀请成员" },
{ "title": "使用一致的对象操作", "description": "右键、长按、更多和键盘打开同一组适用操作,消息正文仍可选择复制。文件列表与卡片视图用紧凑图文选择切换。", "location": "消息或成员 -> 更多;文件 -> 列表或卡片" }
]
},
{
"title": "已有内容与隐私",
"items": [
{ "title": "继续使用原有内容", "description": "已有消息、文件、邀请链接和明暗偏好继续可用。私密直连仍不保存聊天和文件内容,共享空间继续按成员权限保存与访问内容。", "location": "首页 -> 私密直连;首页 -> 共享空间" }
]
}
]
},
{
"version": "0.18.0",
"releasedAt": "2026-09-10",
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const INTERNAL_CHANGELOG_TERMS = [
describe("release history", () => {
it("is unique, complete, and sorted newest first", () => {
expect(new Set(DUAL_LANE_RELEASES.map((release) => release.version)).size).toBe(DUAL_LANE_RELEASES.length);
expect(DUAL_LANE_RELEASES.map((release) => release.version)).toEqual(["0.18.0", "0.17.0", "0.16.2", "0.16.1", "0.16.0", "0.15.5", "0.15.4", "0.15.3", "0.15.2", "0.15.1", "0.15.0", "0.14.3", "0.14.2", "0.14.1", "0.14.0", "0.13.2", "0.13.1", "0.13.0", "0.12.0", "0.11.0", "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.0", "0.4.0", "0.3.0", "0.2.0", "0.1.0"]);
expect(DUAL_LANE_RELEASES.map((release) => release.version)).toEqual(["0.19.0", "0.18.0", "0.17.0", "0.16.2", "0.16.1", "0.16.0", "0.15.5", "0.15.4", "0.15.3", "0.15.2", "0.15.1", "0.15.0", "0.14.3", "0.14.2", "0.14.1", "0.14.0", "0.13.2", "0.13.1", "0.13.0", "0.12.0", "0.11.0", "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.0", "0.4.0", "0.3.0", "0.2.0", "0.1.0"]);
for (const release of DUAL_LANE_RELEASES) {
expect(release.releasedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(release.title.trim()).not.toBe("");
Expand All @@ -42,8 +42,8 @@ describe("release history", () => {
});

it("matches both published package versions", () => {
expect(rootPackage.version).toBe("0.18.0");
expect(webPackage.version).toBe("0.18.0");
expect(rootPackage.version).toBe("0.19.0");
expect(webPackage.version).toBe("0.19.0");
expect(DUAL_LANE_RELEASES[0].version).toBe(rootPackage.version);
expect(DUAL_LANE_RELEASES[0].version).toBe(webPackage.version);
});
Expand Down Expand Up @@ -108,6 +108,7 @@ describe("release history", () => {

it("keeps every Echo usage guide aligned with the public release facts", () => {
const releases = new Map(DUAL_LANE_RELEASES.map((release) => [release.version, release]));
expect(echoReleaseGuides.some((guide) => guide.version === rootPackage.version)).toBe(true);
for (const guide of echoReleaseGuides) {
const release = releases.get(guide.version);
expect(release).toMatchObject({
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ export type DualLaneRelease = {
};

export const DUAL_LANE_RELEASES: DualLaneRelease[] = [
{
version: "0.19.0",
releasedAt: "2026-09-11",
title: "清晰双轨,全新一致体验",
summary: "统一聊天、话题和个人设置,提供五套主题,让阅读、输入与常用操作更加连贯。",
categories: [
{ title: "外观与选择", items: ["在个人设置的外观页选择原色、苔原、暮色、米色或极简主题,横向浏览预览,并独立选择系统、浅色或深色模式", "短选项使用清楚的图文单选,较长选项使用下拉;开关、滑块、菜单和按钮保持一致"] },
{ title: "聊天与话题", items: ["话题与群聊共用完整消息体验,支持图片、文件、表情、回复和适用的消息操作;附件上传时可以继续发送下一条", "本人消息保留独立底色,连续短消息连贯呈现;修复阅读历史时的位置跳动", "话题的参与、提醒和管理操作收进话题详情,聊天区更清爽"] },
{ title: "导航与个人设置", items: ["聊天和话题中栏可以折叠,文件与成员使用单一主列表;普通成员的日常入口更简洁", "个人设置按资料、外观、聊天、通知、隐私、表情及 Bot 分层,手机逐层进入", "表情设置正常显示页面,点击管理我的表情后再打开工具,关闭后留在原页面"] },
{ title: "常用操作", items: ["右键、长按、更多和键盘提供一致的对象操作,正文仍可选择复制", "群聊邀请成员使用带搜索框的多选窗口,部分失败可以只重试未成功成员", "文件列表与卡片视图使用紧凑的图文切换,成员与设置页面的对齐和布局更清晰"] },
{ title: "已有内容与隐私", items: ["已有消息、文件、邀请链接和明暗偏好继续可用,外观偏好保存在本机", "私密直连仍不保存聊天和文件内容,共享空间继续按成员权限保存与访问内容"] }
]
},
{
version: "0.18.0",
releasedAt: "2026-09-10",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/workspace-config-docs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe("workspace configuration docs", () => {
expect(goCompose).toContain("DUALLANE_APP_VERSION: ${DUALLANE_APP_VERSION:?release version is required}");
expect(goCompose).toContain("DUALLANE_GIT_COMMIT: ${DUALLANE_GIT_COMMIT:?full release commit is required}");
expect(goCompose).toContain("image: duallane-go-workspace:${DUALLANE_GIT_COMMIT:?full release commit is required}");
expect(envExample).toContain("DUALLANE_APP_VERSION=0.18.0");
expect(envExample).toContain("DUALLANE_APP_VERSION=0.19.0");
expect(envExample).toContain("DUALLANE_GIT_COMMIT=development");
expect(readme).toMatch(/verified\s+release metadata/);
expect(readme).toContain("/home/timestarry/duallane");
Expand Down
67 changes: 67 additions & 0 deletions docs/backend/work-items/2026-09-11-ui-0190-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 0.19.0 清晰双轨发布记录

日期:2026-09-11。状态:发布准备与生产预检已开展,**尚未激活 0.19.0**。

## 范围与提交

- UI/UX 重构 PR #12 已由 `timeStarry` 合并,合并提交为 `b07d5bfe7f0bc623bc57934866e3175d9adea829`;四项 PR CI 通过。
- 发布准备将根与 Web 版本升至 0.19.0,补齐应用内更新历史、Echo 功能位置引导、示例与 CI 版本标签,以及相应一致性验证。
- 既有 0.18.0 发布历史、SDK 与离线工具独立版本保持不变;没有 schema 变更,仍为 34。新 Web 与同版 Go 镜像一起激活。
- 维护者已明确授权本次上线及远程生产主机访问;按检出所有者更新 Git,以具备所需权限的会话运行协调器。本次访问指令不改变仓库默认部署规则。
- 维护者随后明确授权代理自行检查发布 PR #13 的 CI,全部通过后使用其 GitHub 身份合并并部署;必须核对最终 head,不绕过检查或分支保护。此授权仅适用于本次发布。

最终 UI/UX 规则见 [规范入口](../../design/ui-ux-rewrite/README.md),用户可见变化见
[版本说明](../../design/ui-ux-rewrite/RELEASE_NOTES.md)。本记录不重新定义组件或交互规则。

## 已观察的生产基线

只读检查生产检出 `/home/timestarry/duallane`:干净 `main`,提交
`ef5187348f8b180fa572ae301039c1487496b8c5`,当前运行版本 0.18.0。
P2P、Workspace、worker、Web 四个 owner 均健康,镜像与上一成功快照一致,重启策略为 `always`。

上一成功恢复快照为:

```text
backups/production/duallane-20260910T102919Z-ef5187348f8b.recovery.go-compose.snapshot.json
```

该文件及 `.compose.json`、`.external.json`、`.volumes.json` 配套文件均为 root 持有的
普通 0600 文件。已验证格式、全部回滚镜像仍存在、外部文件指纹和当前实际卷归属;
此处只证明当前基线,激活脚本仍须比较目标版本的实际 authority 并重新校验。
未导出生产消息、令牌、配置正文或私有快照内容。

## 验证与发布边界

UI 重构的完整回归及其修复见 [PR 验证记录](../../design/ui-ux-rewrite/PR_VERIFICATION.md)。
发布准备的检查包括根/Web/Echo 版本一致性、Go 发布目录、真实 Go Echo 引导与公开关于页,
以及类型、构建和既有发布约束;具体结果随发布 PR 记录,不把测试环境当作线上。

本地发布准备检查:Web 333、SDK 8、离线兼容 20 项通过(该离线包原 PostgreSQL 项 1 项按配置跳过);
lint/build 通过;Go 发布目录、配置与 Workspace 启动专项及 Echo PostgreSQL race 通过;
真实 Go 新版 Echo 引导与公开关于页 2/2 通过,桌面/390px 关于页人工查看无横向溢出。
同一工作树的 Linux Node 22.23.2/Compose 2.40.3 发布契约 34/34 通过、无跳过;
Windows 首次运行缺少原生 Docker/Bash 的环境失败没有通过修改断言掩盖。

真机软键盘、安全区域、辅助技术和完整性能基准仍是已披露的验证边界,不能据合并或自动化
结果宣称这些人工检查完成。原有主 JS 超过 500 kB 的构建提示保留。

## 激活与回退

1. 发布准备 PR 的最终 CI 全部通过后,按维护者本次明确授权以 `timeStarry` 身份合并,确认合并提交,再以检出所有者执行 `git pull --ff-only`。
2. 确认生产检出为干净 `main`,HEAD 与最新 `origin/main` 完全一致。使用**发布准备合并后的最终 40 位 SHA**,不能继续使用 UI PR 的合并 SHA。
3. 使用 root 执行正式协调器,设置 `GIT_OPTIONAL_LOCKS=0`;必要的 Git trust 例外仅作用于该进程与准确生产目录。

```sh
deploy/production/deploy.sh --expected-commit <final-merged-40-hex-sha> \
--release-profile go-full --go-upgrade \
--previous-release-snapshot /home/timestarry/duallane/backups/production/duallane-20260910T102919Z-ef5187348f8b.recovery.go-compose.snapshot.json
```

协调器负责新逻辑备份、固定镜像与 SQL 兼容验证、只读候选健康、旧 owner 停写与 drain、
新 owner 激活及最后替换网关。成功后核对公开 Web/静态资源/健康/鉴权边界、四个 owner、
版本与提交号、重启策略、新私有恢复快照和备份校验。

失败只按已验证快照恢复整版 Go 镜像并保持同一数据归属;不自动恢复旧数据库、不反向迁移、
不恢复退休 Node 服务。不得以裸 `docker compose up`、删除现有卷或修改健康条件替代正式流程。

最终激活结果、镜像身份、安全产物名称和开放项记录在发布 PR 的上线记录;未取得这些证据前保持“尚未激活”。
4 changes: 2 additions & 2 deletions docs/design/ui-ux-rewrite/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 待发布:清晰双轨 UI/UX 重构
# 0.19.0 待上线:清晰双轨 UI/UX 重构

这是当前开发分支的发布说明草稿,包含首轮重构后的真实页面结构精修,尚未分配正式版本或发布日期。具体修正和证据见 [页面精修](PAGE_REFINEMENT.md)。准备产品发布时,将审核后的内容纳入既有 release catalog,并同步 Web 与根版本;不把未发布功能写入当前 0.18.0 的历史记录。
本次发布编号为 0.19.0,发布条目日期为 2026-09-11,包含首轮重构后的真实页面结构精修。UI/UX 实现已通过 PR #12 合并;发布准备同步根与 Web 版本、应用内版本历史和 Echo 使用引导,保留 0.18.0 历史条目。具体修正和证据见 [页面精修](PAGE_REFINEMENT.md);合并发布准备不等于已上线,实际激活与恢复证据以 [0.19.0 发布记录](../../backend/work-items/2026-09-11-ui-0190-release.md) 为准。

- 入口、聊天、话题、文件、成员、个人与空间设置采用统一的清晰双轨语言。细线选中标记与圆角边框形成同一套导航规则。
- 提供原色、苔原、暮色、米色、极简五组主题及其浅深模式。主题预览只保留标题,可横向滑动或拖动浏览,也支持左右按钮和键盘选择。密度、动态效果和透明材质可独立设置;此前明暗偏好自动迁移,本机偏好读取失败会安全回退。
Expand Down
17 changes: 9 additions & 8 deletions e2e/workspace-echo-release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,24 @@ test("space owner broadcasts one detailed mobile release guide through Echo", as

const chat = page.getByRole("region", { name: "回声" });
const composer = chat.getByLabel("输入消息");
await composer.fill("/release 0.15.1");
await composer.fill("/release 0.19.0");
await chat.locator("form.workspace-composer button.workspace-send-button").click();

const interaction = chat.getByRole("region", { name: "回声交互" });
await expect(interaction.getByText("v0.15.1 更新已发布", { exact: true })).toBeVisible();
const card = chat.getByRole("group", { name: /DualLane v0\.15\.1 版本更新/ });
await expect(interaction.getByText("v0.19.0 更新已发布", { exact: true })).toBeVisible();
const card = chat.getByRole("group", { name: /DualLane v0\.19\.0 版本更新/ });
await expect(card).toBeVisible();
await expect(card.getByText("表情合集订阅", { exact: true })).toBeVisible();
await expect(card.getByText(/个人设置 -> 我的表情 -> 打开合集详情/)).toBeVisible();
await expect(card.getByText("五套主题与独立明暗模式", { exact: true })).toBeVisible();
await expect(card.getByText("在话题中使用完整消息", { exact: true })).toBeVisible();
await expect(card.getByText("个人 -> 外观", { exact: true })).toBeVisible();
const bounds = await card.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);

await interaction.getByRole("button", { name: "完成" }).click();
await composer.fill("/release v0.15.1");
await composer.fill("/release v0.19.0");
await chat.locator("form.workspace-composer button.workspace-send-button").click();
await expect(interaction.getByText("v0.15.1 更新已发布", { exact: true })).toBeVisible();
await expect(chat.getByRole("group", { name: /DualLane v0\.15\.1 版本更新/ })).toHaveCount(1);
await expect(interaction.getByText("v0.19.0 更新已发布", { exact: true })).toBeVisible();
await expect(chat.getByRole("group", { name: /DualLane v0\.19\.0 版本更新/ })).toHaveCount(1);
});
Loading
Loading