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
132 changes: 132 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1345,3 +1345,135 @@ jobs:
test "$draft" = "false"
test "$tag" = "$RELEASE_TAG"
test "$target" = "$SOURCE_COMMIT"

mirror-r2:
name: Mirror latest Release to R2
needs: [source, prepare-release, publish-release]
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ vars.R2_BUCKET }}
R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }}
RELEASE_TAG: ${{ needs.source.outputs.tag }}
steps:
- name: Checkout validated source
uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.commit }}

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true

- name: Download validated release candidate
uses: actions/download-artifact@v4
with:
name: agentdock-release-dist
path: dist

- name: Validate R2 mirror configuration
shell: bash
run: |
set -euo pipefail
for name in R2_ACCOUNT_ID R2_BUCKET R2_PUBLIC_BASE_URL AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [[ -z "${!name:-}" ]]; then
echo "$name is required for the R2 release mirror." >&2
exit 1
fi
done
[[ "$R2_PUBLIC_BASE_URL" == https://* ]]
command -v aws >/dev/null
aws --version

- name: Prepare R2 bootstrap assets
shell: bash
run: |
set -euo pipefail
release_base="${R2_PUBLIC_BASE_URL%/}/releases/$RELEASE_TAG"
go run ./tools/release mirror-prepare "$release_base" dist
(cd dist && sha256sum -c install.sh.sha256)

- name: Generate latest mirror manifest
shell: bash
run: |
set -euo pipefail
release_base="${R2_PUBLIC_BASE_URL%/}/releases/$RELEASE_TAG"
go run ./tools/release mirror-manifest "$RELEASE_TAG" "$release_base" dist > dist/latest.json
jq -e --arg tag "$RELEASE_TAG" '.tag_name == $tag and (.assets | length > 0)' dist/latest.json >/dev/null

- name: Upload and verify versioned R2 assets
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
release_prefix="releases/$RELEASE_TAG"

while IFS= read -r asset; do
key="$release_prefix/$asset"
aws s3 cp "dist/$asset" "s3://$R2_BUCKET/$key" \
--endpoint-url "$endpoint" \
--cache-control "public, max-age=31536000, immutable" \
--only-show-errors
aws s3api head-object \
--bucket "$R2_BUCKET" \
--key "$key" \
--endpoint-url "$endpoint" >/dev/null
done < <(jq -r '.assets[].name' dist/latest.json)

- name: Verify public versioned assets
shell: bash
run: |
set -euo pipefail
while IFS= read -r url; do
curl --fail --silent --show-error --location \
--retry 5 --retry-all-errors \
--head "$url" >/dev/null
done < <(jq -r '.assets[].browser_download_url' dist/latest.json)

- name: Publish latest.json
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp dist/latest.json "s3://$R2_BUCKET/latest.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "no-cache, max-age=0, must-revalidate" \
--only-show-errors

manifest_url="${R2_PUBLIC_BASE_URL%/}/latest.json?run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
curl --fail --silent --show-error --location \
--retry 5 --retry-all-errors \
-H "Cache-Control: no-cache" \
"$manifest_url" \
-o "$RUNNER_TEMP/published-latest.json"
jq -e --arg tag "$RELEASE_TAG" '.tag_name == $tag and (.assets | length > 0)' \
"$RUNNER_TEMP/published-latest.json" >/dev/null

- name: Remove stale R2 release objects
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
current_prefix="releases/$RELEASE_TAG/"
aws s3api list-objects-v2 \
--bucket "$R2_BUCKET" \
--prefix "releases/" \
--endpoint-url "$endpoint" \
--output json \
| jq -r '.Contents[]?.Key' \
> "$RUNNER_TEMP/r2-release-keys"

while IFS= read -r key; do
[[ -z "$key" || "$key" == "$current_prefix"* ]] && continue
aws s3api delete-object \
--bucket "$R2_BUCKET" \
--key "$key" \
--endpoint-url "$endpoint" >/dev/null
done < "$RUNNER_TEMP/r2-release-keys"
60 changes: 60 additions & 0 deletions docs/release-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Release 下载分发

AgentDock 的正式版本仍以 GitHub Release 为唯一历史档案。`Release` workflow 在正式 GitHub Release 发布成功后,把当前最新版镜像到 Cloudflare R2,供 NexusDock 官网和后续客户端更新使用。

## R2 对象布局

R2 只保留当前稳定版:

```text
latest.json
releases/
└── vX.Y.Z/
├── AgentDockSetup-amd64.exe
├── AgentDockSetup-amd64.exe.sha256
├── AgentDockSetup-arm64.exe
├── AgentDockSetup-arm64.exe.sha256
├── AgentDock-macos-universal.dmg
├── AgentDock-macos-universal.dmg.sha256
├── AgentDock-macos-universal.zip
├── AgentDock-macos-universal.zip.sha256
├── agentdock_{linux,darwin,windows}_{amd64,arm64}.*
├── install.sh
├── install.sh.sha256
├── install.ps1
└── install.ps1.sha256
```

`latest.json` 使用 GitHub Release API 的最小兼容结构:`tag_name` 和 `assets[].name/browser_download_url`。这样客户端以后可以把最新版元数据源切到 NexusDock 下载域名,而不需要维护第二套 Release 数据模型。

发布顺序是:

1. GitHub Release 经过现有跨平台验证并正式公开。
2. 下载已验证的 `agentdock-release-dist`。
3. 上传 `releases/<tag>/` 下的不可变对象并通过 R2 API 校验。
4. 从公开下载域名逐个验证版本化 URL。
5. 最后更新根目录 `latest.json`,避免元数据提前指向未完成上传的版本。
6. `latest.json` 验证成功后,删除 `releases/` 下其他版本对象。

R2 中的 `install.sh` 会在镜像阶段把默认 Release 基础地址改写为同版本的
`https://download.nexusdock.co/releases/<tag>`,并重新生成 `install.sh.sha256`。
GitHub Release 中的原始 `install.sh` 不变,因此历史版本仍保持 GitHub 自身的可复现下载链路。

任何步骤在更新 `latest.json` 前失败时,旧版下载入口保持不变;更新之后的清理失败只会留下旧对象,不会破坏当前最新版。

## GitHub Actions 配置

Repository Variables:

- `R2_ACCOUNT_ID`:Cloudflare Account ID。
- `R2_BUCKET`:用于最新版安装包镜像的 R2 bucket。
- `R2_PUBLIC_BASE_URL`:R2 自定义公开域名,例如 `https://download.nexusdock.co`,不要带末尾 `/`。

Repository Secrets:

- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`

R2 凭据只需要目标 bucket 的对象读写权限,不需要 Cloudflare 账号级管理员权限。

历史版本、Release Notes 和旧安装包继续从 GitHub Releases 获取。R2 不承担历史归档职责。
9 changes: 9 additions & 0 deletions tools/release/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func ReleaseCatalog() []Artifact {
{Name: "agentdock_windows_amd64.zip", Kind: "binary-archive", Platform: "windows", Arch: "amd64", Required: true},
{Name: "agentdock_windows_arm64.zip", Kind: "binary-archive", Platform: "windows", Arch: "arm64", Required: true},
{Name: "AgentDock-macos-universal.dmg", Kind: "disk-image", Platform: "darwin", Arch: "universal", Required: true},
{Name: "AgentDock-macos-universal.zip", Kind: "desktop-archive", Platform: "darwin", Arch: "universal", Required: true},
{Name: "AgentDockSetup-amd64.exe", Kind: "setup", Platform: "windows", Arch: "amd64", Required: true, PublicContract: true},
{Name: "AgentDockSetup-arm64.exe", Kind: "setup", Platform: "windows", Arch: "arm64", Required: true, PublicContract: true},
}
Expand All @@ -79,5 +80,13 @@ func ReleaseCatalog() []Artifact {
})
}
catalog = append(catalog, scripts...)
for _, artifact := range scripts {
catalog = append(catalog, Artifact{
Name: artifact.Name + ".sha256",
Kind: "checksum",
Platform: artifact.Platform,
Required: artifact.Required,
})
}
return catalog
}
12 changes: 11 additions & 1 deletion tools/release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func main() {

func run(args []string, stdout io.Writer) error {
if len(args) == 0 {
return errors.New("用法:release <catalog|version|verify-version|verify-dist|checksum> [参数]")
return errors.New("用法:release <catalog|version|verify-version|verify-dist|checksum|mirror-prepare|mirror-manifest> [参数]")
}
switch args[0] {
case "version":
Expand Down Expand Up @@ -55,6 +55,16 @@ func run(args []string, stdout io.Writer) error {
}
fmt.Fprintf(stdout, "%s %s\n", sum, filepath.Base(args[1]))
return nil
case "mirror-manifest":
if len(args) != 4 {
return errors.New("用法:release mirror-manifest <tag> <public-base-url> <dist-dir>")
}
return writeMirrorManifest(args[1], args[2], args[3], stdout)
case "mirror-prepare":
if len(args) != 3 {
return errors.New("用法:release mirror-prepare <public-base-url> <dist-dir>")
}
return prepareMirrorBootstrap(args[1], args[2])
default:
return fmt.Errorf("未知命令:%s", args[0])
}
Expand Down
126 changes: 126 additions & 0 deletions tools/release/mirror.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
)

type mirrorRelease struct {
TagName string `json:"tag_name"`
Assets []mirrorReleaseAsset `json:"assets"`
}

type mirrorReleaseAsset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}

func prepareMirrorBootstrap(publicBaseURL, distDir string) error {
baseURL, err := normalizeMirrorBaseURL(publicBaseURL)
if err != nil {
return err
}
if strings.TrimSpace(distDir) == "" {
return errors.New("mirror dist 目录不能为空")
}

installPath := filepath.Join(distDir, "install.sh")
info, err := os.Stat(installPath)
if err != nil {
return fmt.Errorf("检查 mirror install.sh 失败: %w", err)
}
if !info.Mode().IsRegular() {
return errors.New("mirror install.sh 不是普通文件")
}

content, err := os.ReadFile(installPath)
if err != nil {
return fmt.Errorf("读取 mirror install.sh 失败: %w", err)
}
lines := strings.Split(string(content), "\n")
const prefix = `DEFAULT_BASE_URL="`
replaced := false
for i, line := range lines {
if !strings.HasPrefix(line, prefix) {
continue
}
if replaced {
return errors.New("mirror install.sh 包含多个 DEFAULT_BASE_URL")
}
lines[i] = fmt.Sprintf(`DEFAULT_BASE_URL="%s"`, baseURL)
replaced = true
}
if !replaced {
return errors.New("mirror install.sh 缺少 DEFAULT_BASE_URL")
}

if err := os.WriteFile(installPath, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil {
return fmt.Errorf("写入 mirror install.sh 失败: %w", err)
}
sum, err := fileSHA256(installPath)
if err != nil {
return fmt.Errorf("计算 mirror install.sh SHA-256 失败: %w", err)
}
checksum := fmt.Sprintf("%s install.sh\n", sum)
if err := os.WriteFile(filepath.Join(distDir, "install.sh.sha256"), []byte(checksum), 0o644); err != nil {
return fmt.Errorf("写入 mirror install.sh.sha256 失败: %w", err)
}
return nil
}

func writeMirrorManifest(tag, publicBaseURL, distDir string, stdout io.Writer) error {
tag = strings.TrimSpace(tag)
if tag == "" || strings.ContainsAny(tag, `/\`) {
return fmt.Errorf("无效 Release tag:%q", tag)
}
baseURL, err := normalizeMirrorBaseURL(publicBaseURL)
if err != nil {
return err
}
if strings.TrimSpace(distDir) == "" {
return errors.New("mirror dist 目录不能为空")
}

manifest := mirrorRelease{TagName: tag}
for _, artifact := range ReleaseCatalog() {
path := filepath.Join(distDir, artifact.Name)
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("mirror dist 缺少 Release 资产:%s", artifact.Name)
}
return fmt.Errorf("检查 mirror Release 资产 %s 失败: %w", artifact.Name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("mirror Release 资产不是普通文件:%s", artifact.Name)
}
manifest.Assets = append(manifest.Assets, mirrorReleaseAsset{
Name: artifact.Name,
URL: baseURL + "/" + artifact.Name,
})
}
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(manifest)
}

func normalizeMirrorBaseURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("解析 mirror public base URL 失败: %w", err)
}
if parsed.Scheme != "https" || parsed.Host == "" {
return "", errors.New("mirror public base URL 必须是完整 HTTPS 地址")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("mirror public base URL 不能包含 query 或 fragment")
}
return strings.TrimRight(parsed.String(), "/"), nil
}
Loading
Loading