Skip to content

Commit 67d62f5

Browse files
committed
feat(collection): publish VRChat collector releases independently
1 parent 18e30df commit 67d62f5

17 files changed

Lines changed: 753 additions & 47 deletions
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
name: Collector Release · VRChat
2+
run-name: VRChat Collector Release · ${{ github.ref_name }}
3+
4+
on:
5+
push:
6+
tags: ['collector-vrchat/v*']
7+
8+
concurrency:
9+
group: collector-vrchat-release-${{ github.ref }}
10+
cancel-in-progress: false
11+
12+
permissions:
13+
contents: read
14+
15+
env:
16+
DOTNET_VERSION: '10.0.x'
17+
NODE_VERSION: '24'
18+
PACKAGE_ID: heartbeat.collector.vrchat
19+
REGISTRY_BASE_URL: https://heartbeat.shenxianovo.com/collector-registry/v1
20+
21+
jobs:
22+
build:
23+
name: Build immutable linux-x64 Package release
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 30
26+
outputs:
27+
version: ${{ steps.version.outputs.version }}
28+
artifact_name: ${{ steps.release.outputs.artifact_name }}
29+
steps:
30+
- name: Checkout the tagged commit
31+
uses: actions/checkout@v7
32+
with:
33+
fetch-depth: 0
34+
35+
- name: Validate the dedicated Collector tag
36+
id: version
37+
shell: bash
38+
run: |
39+
set -Eeuo pipefail
40+
if [[ "$GITHUB_REF_NAME" =~ ^collector-vrchat/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
41+
version="${GITHUB_REF_NAME#collector-vrchat/v}"
42+
else
43+
echo "Expected collector-vrchat/vX.Y.Z, got: $GITHUB_REF_NAME" >&2
44+
exit 1
45+
fi
46+
echo "version=$version" >> "$GITHUB_OUTPUT"
47+
48+
- name: Set up .NET
49+
uses: actions/setup-dotnet@v6
50+
with:
51+
dotnet-version: ${{ env.DOTNET_VERSION }}
52+
53+
- name: Set up Node.js
54+
uses: actions/setup-node@v6
55+
with:
56+
node-version: ${{ env.NODE_VERSION }}
57+
58+
- name: Verify Collector behavior and shared contracts
59+
run: |
60+
dotnet test collection/collectors/Heartbeat.Collector.VRChat.Tests/Heartbeat.Collector.VRChat.Tests.csproj \
61+
-c Release
62+
node scripts/collector-contracts.mjs check
63+
64+
- name: Set up Docker Buildx
65+
uses: docker/setup-buildx-action@v3
66+
67+
- name: Build the tagged VRChat Collector Package
68+
shell: bash
69+
run: |
70+
set -Eeuo pipefail
71+
docker buildx build \
72+
--platform linux/amd64 \
73+
--file collection/collectors/Heartbeat.Collector.VRChat/Dockerfile \
74+
--target package \
75+
--build-arg "COLLECTOR_VERSION=${{ steps.version.outputs.version }}" \
76+
--output type=local,dest=collector-package \
77+
.
78+
79+
- name: Assemble the immutable Web release
80+
id: release
81+
shell: bash
82+
run: |
83+
set -Eeuo pipefail
84+
version='${{ steps.version.outputs.version }}'
85+
./scripts/package-vrchat-release.sh \
86+
--package collector-package \
87+
--version "$version" \
88+
--output release-output \
89+
--base-url "$REGISTRY_BASE_URL"
90+
91+
artifact_name="$PACKAGE_ID-$version-linux-x64.zip"
92+
test -f "release-output/$artifact_name"
93+
test -f release-output/release.json
94+
test "$(jq -r .packageId release-output/release.json)" = "$PACKAGE_ID"
95+
test "$(jq -r .version release-output/release.json)" = "$version"
96+
test "$(jq -r .artifact.fileName release-output/release.json)" = "$artifact_name"
97+
98+
mkdir extracted
99+
unzip -q "release-output/$artifact_name" -d extracted
100+
test -f extracted/collector-manifest.json
101+
test -x extracted/Heartbeat.Collector.VRChat
102+
test "$(jq -r .version extracted/collector-manifest.json)" = "$version"
103+
test "$(jq -r .packageId extracted/collector-manifest.json)" = "$PACKAGE_ID"
104+
test "$(jq -r '.artifacts | length' extracted/collector-manifest.json)" = 1
105+
test "$(jq -r '.artifacts[0].selector.driver' extracted/collector-manifest.json)" = managedProcess
106+
test "$(jq -r '.artifacts[0].selector.os | join(",")' extracted/collector-manifest.json)" = linux
107+
test "$(jq -r '.artifacts[0].selector.arch | join(",")' extracted/collector-manifest.json)" = x64
108+
echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT"
109+
110+
- name: Retain the exact release files for diagnosis
111+
uses: actions/upload-artifact@v7
112+
with:
113+
name: collector-vrchat-${{ steps.version.outputs.version }}-linux-x64
114+
path: release-output/*
115+
if-no-files-found: error
116+
compression-level: 0
117+
retention-days: 7
118+
119+
publish:
120+
name: Publish immutable static files
121+
needs: build
122+
runs-on: ubuntu-latest
123+
timeout-minutes: 15
124+
steps:
125+
- name: Download the exact release files
126+
uses: actions/download-artifact@v8
127+
with:
128+
name: collector-vrchat-${{ needs.build.outputs.version }}-linux-x64
129+
path: release-output
130+
131+
- name: Prepare isolated server staging
132+
uses: appleboy/ssh-action@v1
133+
with:
134+
host: ${{ secrets.SERVER_HOST }}
135+
username: ${{ secrets.SERVER_USER }}
136+
key: ${{ secrets.SERVER_SSH_KEY }}
137+
script: |
138+
set -eu
139+
staging='/tmp/heartbeat-collector-release-${{ github.run_id }}-${{ github.run_attempt }}'
140+
rm -rf -- "$staging"
141+
mkdir -p -- "$staging"
142+
143+
- name: Upload to isolated server staging
144+
uses: appleboy/scp-action@v1
145+
with:
146+
host: ${{ secrets.SERVER_HOST }}
147+
username: ${{ secrets.SERVER_USER }}
148+
key: ${{ secrets.SERVER_SSH_KEY }}
149+
source: release-output
150+
target: /tmp/heartbeat-collector-release-${{ github.run_id }}-${{ github.run_attempt }}
151+
152+
- name: Install the immutable version directory
153+
uses: appleboy/ssh-action@v1
154+
with:
155+
host: ${{ secrets.SERVER_HOST }}
156+
username: ${{ secrets.SERVER_USER }}
157+
key: ${{ secrets.SERVER_SSH_KEY }}
158+
script: |
159+
set -eu
160+
package_id='${{ env.PACKAGE_ID }}'
161+
version='${{ needs.build.outputs.version }}'
162+
artifact='${{ needs.build.outputs.artifact_name }}'
163+
staging='/tmp/heartbeat-collector-release-${{ github.run_id }}-${{ github.run_attempt }}'
164+
source="$staging/release-output"
165+
registry='/srv/heartbeat/collector-registry/v1'
166+
versions="$registry/packages/$package_id/versions"
167+
target="$versions/$version"
168+
pending="$versions/.$version.staging-${{ github.run_id }}-${{ github.run_attempt }}"
169+
170+
test -f "$source/$artifact"
171+
test -f "$source/release.json"
172+
install -d -m 0755 -- "$versions"
173+
174+
if [ -d "$target" ]; then
175+
if cmp -s "$source/$artifact" "$target/$artifact" && \
176+
cmp -s "$source/release.json" "$target/release.json"; then
177+
echo "Exact release already exists; treating rerun as idempotent."
178+
rm -rf -- "$staging"
179+
exit 0
180+
fi
181+
echo "Refusing to overwrite $package_id $version with different bytes." >&2
182+
exit 1
183+
fi
184+
185+
rm -rf -- "$pending"
186+
mkdir -- "$pending"
187+
install -m 0644 "$source/$artifact" "$pending/$artifact"
188+
install -m 0644 "$source/release.json" "$pending/release.json"
189+
mv -- "$pending" "$target"
190+
rm -rf -- "$staging"
191+
192+
- name: Verify the public exact release bytes
193+
shell: bash
194+
run: |
195+
set -Eeuo pipefail
196+
version='${{ needs.build.outputs.version }}'
197+
artifact='${{ needs.build.outputs.artifact_name }}'
198+
url="$REGISTRY_BASE_URL/packages/$PACKAGE_ID/versions/$version"
199+
curl --fail --silent --show-error --location \
200+
--retry 12 --retry-delay 5 \
201+
"$url/$artifact?release-run=$GITHUB_RUN_ID" \
202+
--output "$RUNNER_TEMP/$artifact"
203+
curl --fail --silent --show-error --location \
204+
--retry 12 --retry-delay 5 \
205+
"$url/release.json?release-run=$GITHUB_RUN_ID" \
206+
--output "$RUNNER_TEMP/release.json"
207+
cmp release-output/$artifact "$RUNNER_TEMP/$artifact"
208+
cmp release-output/release.json "$RUNNER_TEMP/release.json"

.scratch/collector-package-registry/PRD.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ Collector Runtime、Protocol 与三类 Execution Driver 已经存在。第一条
88
image,并建立 Desktop/Headless 共享的 Installation module;Backend workflow 也已停止顺带部署 Headless。
99
宿主组合已按 [ADR-049](../../docs/adr/049-named-optional-collectors-outside-host-composition.md) 收敛:Desktop
1010
与通用 Hub Runtime 只组合通用 seam 加 System BuiltIn,不认识任何具名可选 Collector。
11-
当前仍缺 Headless 独立 deploy、服务器 Package provision、Collector tag/Web 发布,以及通用 ExternalHost 的
12-
安装/连接能力,因此各发布单元尚未全部形成可部署闭环。
11+
VRChat 的显式 tag 与不可变 Web Release workflow 已完成代码实现,但生产 Caddy 路由和首个 tag 仍是
12+
`ready-for-human`;当前仍缺 Headless 独立 deploy、服务器 Package provision、Host Web Package source 与
13+
通用 ExternalHost 的安装/连接能力,因此各发布单元尚未全部形成可部署闭环。
1314

1415
Browser 现在的状态是"有独立发布单元、无宿主接入能力":它不进 Desktop 构建与产物,扩展代码、Package
1516
构建 target 与 npm 测试留在 `collection/collectors/Heartbeat.Collector.Browser` 并由 `collector-contracts.yml`
1617
验证;但宿主里没有 Browser runtime、protocol handler、安装目录或 UI 条目,`/v1/collector-protocol/browser`
1718
也不存在,因此手工侧载不再能让它连上宿主。通用 ExternalHost 安装/连接是后续 issue。
1819

19-
2026-09-01 以前的 Registry/Approve/Switch 实现已撤回。旧 issues 01–07 均是历史规格,除非按
20-
[ADR-048](../../docs/adr/048-shared-collector-host-runtime-and-independent-release-units.md) 重写,否则不能作为
21-
Agent 实现指令。
20+
2026-09-01 以前的 Registry/Approve/Switch 实现已撤回。issue 02 已按 ADR-048 重写;issues 01、06、07
21+
仍是历史规格,重写前不能作为 Agent 实现指令。
2222

2323
## Outcome
2424

@@ -69,7 +69,7 @@ Browser 独立发布与真实 smoke。
6969
| Issue | 状态 | 新路径 |
7070
|---|---|---|
7171
| 01 static registry index | needs-triage | Web source 阶段重写 |
72-
| 02 explicit release pipeline | needs-triage | VRChat tag/static publish 阶段重写 |
72+
| 02 explicit release pipeline | ready-for-human | workflow 已实现;待 Caddy 配置与首个真实 tag |
7373
| 03 shared local installation | ready-for-human | PowerShell CLI 安全/真实构建待跨平台验证 |
7474
| 04 exact package approval | wontfix | ADR-048 明确不做 approval/offer |
7575
| 05 VRChat ready switch | wontfix | ADR-048 明确不做 candidate/LKG switch |
@@ -122,3 +122,10 @@ Protocol)加 System BuiltIn,决策记在
122122
Browser 从 Desktop UI 消失。Browser 的独立 Package 构建与契约验证保留;它恢复连接后可直接使用
123123
`facts.segment/v1` 通用投影,不需要 Hub 增加 Browser schema 分支。剩余缺口只有通用 ExternalHost
124124
安装/连接能力与独立 Web Delivery。
125+
126+
### 2026-09-02 — issue 02 代码完成:VRChat 精确 Web Release
127+
128+
VRChat 现在有独立的 `collector-vrchat/vX.Y.Z` tag workflow:固定构建 `linux-x64` Package,生成确定性 zip
129+
与不可变 `release.json`,再向服务器静态目录追加精确 Version,并从公网逐字节回读。它不创建 current
130+
pointer,也不触碰 Desktop、Headless、Frontend 或 Analytics。issue 保持 `ready-for-human`:生产 Caddy
131+
静态路由、服务器 x86_64 确认和首个真实 tag 尚未执行。
Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,99 @@
1-
# 02 — 建立 VRChat 显式 tag release pipeline
1+
# 02 — VRChat Collector 显式 tag 与不可变 Web Release
22

3-
Status: needs-triage
3+
Status: ready-for-human
44

55
Owner: Build / Release
66

7-
Priority: P1 — 第一条纵切必须证明 VRChat 不再依赖 Headless/Desktop 一起发布。
7+
Priority: P1 — 用第一份真实 Web Release 证明非 BuiltIn Collector 可以脱离 Desktop、Headless、Frontend 与
8+
Analytics 独立发版,并给后续 Web Package source adapter 一个真实制品。
89

910
## What to build
1011

11-
让 VRChat 只在自己的显式 SemVer tag 上构建当前真实运行平台的 artifact,生成 index 所需的 Version、
12-
URL、length 与 SHA-256 并发布到静态 Web 目录。普通 main CI 只做 dry-run,不发布用户可见候选。
12+
只建立 VRChat Collector 的显式发布纵切:
13+
14+
1. 只有 `collector-vrchat/vX.Y.Z` tag 能触发发布;tag 版本进入程序集、Package manifest 与 Release metadata。
15+
2.`linux/amd64` 容器内构建当前生产 Headless 目标的 framework-dependent Collector Package。
16+
3. 把 Package 组装为可重复的 zip,并生成同目录的不可变 `release.json`,记录精确 URL、字节长度与 SHA-256。
17+
4. 先上传到服务器隔离 staging,再原子加入静态 Registry 的精确 Version 目录;已存在版本只允许完全相同的
18+
workflow rerun,任何字节差异都拒绝覆盖。
19+
5. 发布后从 `https://heartbeat.shenxianovo.com/collector-registry/v1/` 公网回读 artifact 与 metadata,逐字节
20+
比较本次构建结果。
21+
22+
静态目录由服务器 Caddy 直接提供,不进入 Frontend image;发布不构建、不重启 Desktop、Headless、Frontend
23+
或 Analytics。
24+
25+
## Release contract
26+
27+
```text
28+
/collector-registry/v1/
29+
packages/heartbeat.collector.vrchat/versions/{version}/
30+
heartbeat.collector.vrchat-{version}-linux-x64.zip
31+
release.json
32+
```
33+
34+
`release.json` v1 只描述一个精确 Release:`schemaVersion``packageId``version``target { os, arch }`
35+
`artifact { fileName, url, length, sha256 }`。它不是 channel/current pointer,不做版本解析。
1336

1437
## Acceptance
1538

16-
- [ ] `collector-vrchat/vX.Y.Z` 是唯一发布 trigger;错误 package/tag、非 SemVer 或 dirty generated contract
17-
使 pipeline fail closed。
18-
- [ ] artifact 可独立运行并带 Package manifest;pipeline 计算 length/hash 并生成 issue 01 的 index entry。
19-
- [ ] artifact 是当前 Headless 实际环境可运行的 framework-dependent VRChat zip;不生成 self-contained 或
20-
多平台矩阵。
21-
- [ ] artifact 先上传并可读,随后才替换 index;index 不得指向尚不存在的文件。
22-
- [ ] 已存在的 Version 不得覆盖;同 tag 重跑只有在远端 artifact 与 index 完全一致时才幂等,否则失败。
23-
- [ ] main/PR workflow 以临时目录完成端到端 dry-run,不写部署中的 Registry。
24-
- [ ] System Collector 明确排除在 Web release matrix 外。
25-
- [ ] Browser、签名、key rotation、撤回与多平台矩阵不进入本 issue。
39+
- [x] `.github/workflows/release-collector-vrchat.yml` 只监听 `collector-vrchat/v*`,并用严格正则接受稳定
40+
`X.Y.Z`;普通 `main`、PR 与 Desktop tag 不发布 VRChat。
41+
- [x] tag 版本进入 VRChat 程序集、API user agent 与 `collector-manifest.json`,Package manifest/tag 不一致时
42+
release assembler fail closed。
43+
- [x] tag job 固定 `linux/amd64`,产物只声明一个 `managedProcess + linux + x64` artifact,不生成
44+
self-contained 或多平台矩阵。
45+
- [x] `package-vrchat-release.sh` 从已构建 Package 生成确定性 zip 与 `release.json`;metadata 绑定同域 HTTPS
46+
精确 URL、真实 length 与 SHA-256。
47+
- [x] zip 解包后 manifest 位于根目录,ManagedProcess entrypoint 保留 executable bit。
48+
- [x] 服务器发布只追加 `/versions/{version}`:先进入同父目录 staging,再 rename;同版本同字节幂等,
49+
同版本异字节拒绝覆盖。
50+
- [x] workflow 在发布后经公网回读 zip 与 `release.json`,与 runner 上的构建结果逐字节比较。
51+
- [x] 普通 Collector Contracts CI 会运行 VRChat.Tests,其中 release assembler 的本地 dry-run 覆盖确定性、
52+
metadata 与 Package/tag 版本冲突。
53+
- [x] System、Browser、current pointer、Runtime 下载/安装、签名、channel、撤回、回滚与多平台矩阵不进入本
54+
issue。
55+
- [ ] owner 在生产服务器完成 Caddy 静态路由与目录权限的一次性配置,确认服务器为 x86_64,并推送首个
56+
`collector-vrchat/vX.Y.Z` tag;真实 workflow 与公网回读全绿。
57+
58+
## Verification
59+
60+
- `dotnet test collection/collectors/Heartbeat.Collector.VRChat.Tests/Heartbeat.Collector.VRChat.Tests.csproj -c Release`
61+
- `node scripts/collector-contracts.mjs check`
62+
- `bash -n scripts/package-vrchat-release.sh`
63+
- 用本地 Package 连续生成两份 Release,zip 字节完全相同,metadata 的 length/hash 与文件一致。
64+
- YAML parse 与 `git diff --check`
65+
66+
2026-09-02 本机验证:VRChat.Tests **21 passed / 0 failed**;Collector contracts 通过;真实
67+
`linux/amd64` Docker Buildx 以 `COLLECTOR_VERSION=0.2.0` 连续构建两次,两份 Package 都声明
68+
`version=0.2.0``managedProcess + linux + x64`,组装后的 zip 与 `release.json` 逐字节相同;zip 解包后
69+
entrypoint 保留 executable bit。全仓 Release build 为 0 warning / 0 error,12 个测试项目合计
70+
**1038 passed / 0 failed**`actionlint`、ShellCheck、YAML parse、`bash -n`、IDE1006 格式门禁与
71+
`git diff --check` 通过。
72+
73+
生产 tag 尚未推送,因此服务器 staging、不可变冲突路径、Caddy 公网回读均是 human gate,不宣称已验证。
74+
首个正式 tag 建议使用 `collector-vrchat/v0.2.0`,避免把当前本地 0.1.0 Installation 的不同内容误认成同一
75+
Web Release。
76+
77+
## Non-goals
78+
79+
- 不实现 `current.json`、stable channel、SemVer solver 或 Runtime Web Package source adapter;
80+
- 不自动安装、批准、激活或替换 Headless 中正在运行的 Collector Instance;
81+
- 不建立 Ed25519、第三方市场、撤回、LKG、自动回滚或 cache GC;
82+
- 不发布 Browser,也不改变 System BuiltIn Delivery;
83+
- 不创建或部署 Headless workflow。
2684

2785
## Dependencies
2886

29-
与 issue 01 并行开发,但合并前必须消费其最终 schema/fixtures。
87+
- [ADR-048](../../../docs/adr/048-shared-collector-host-runtime-and-independent-release-units.md) 的独立发布单元边界;
88+
- [issue 03](./03-shared-local-package-installation.md) 已提供外置 VRChat Package builder 与共享 Installation。
89+
90+
后续 Web Package source adapter 应消费本 issue 的真实 immutable Release,再决定是否需要独立的 current
91+
pointer;不得提前恢复 ADR-045/047 已撤回的 approval/LKG 状态机。
92+
93+
## Comments
94+
95+
### 2026-09-02 — 从旧 Registry 规格缩成单一发布纵切
96+
97+
旧 issue 同时要求 mutable index、发布工具、Runtime reader 与完整本地 Registry fixture,实际上把 artifact
98+
发布和发现/下载混成了一个 feature。本轮只落不可变精确 Release:它已经能独立下载,也为下一条 Web source
99+
adapter 提供真实输入;没有 current pointer 就没有第二份“当前版本”权威。

.scratch/collector-package-registry/issues/07-deploy-and-vrchat-smoke.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Status: needs-triage
44

5+
> 2026-09-02:本 issue 仍是 ADR-045/047 时代的 approval/LKG 规格,不能执行。issue 02 已重写并承接
6+
> 「显式 tag → 不可变精确 Web Release → Caddy 公网回读」;本 issue 后续只应重写 Headless 独立 deploy、
7+
> Web Package source 安装与真实 VRChat smoke,不得恢复 CheckNow/Approve/LKG 状态机。
8+
59
Owner: Release / Operator
610

711
Priority: P1 — 域名路由与真实 VRChat 更新需要 owner 操作和观察。

0 commit comments

Comments
 (0)