本文为 AtomGit 码动四季·开源同行 征稿活动参与文章

本文以 ClickClaw 为例,讲清楚一个真实问题。
ClickClaw 是一个面向小白用户的 OpenClaw 桌面管理工具,支持 Windows/macOS,提供可视化配置、模型管理、聊天渠道配置和 Skill 技能市场。

项目 AtomGit 仓库地址:https://atomgit.com/clickclaw/clickclaw

在这个项目里,我们使用 GitHub Actions 自动构建 Windows/macOS 安装包;同时因为国内用户从 GitHub 下载较慢,我们将 Tag、Release 与附件自动同步到 AtomGit,并通过官方代理 + hosts 方案优化上传速度。

适用对象:

  1. Electron 或其他需要产出二进制安装包的项目
  2. 同时维护 GitHub 与 AtomGit 两个平台仓库的团队
  3. 希望把“发版 + 跨平台同步”全部自动化的团队

1. 场景与问题

我们的实际发布链路是:

  1. 项目类型:Electron 桌面应用
  2. 构建目标:Windows + macOS 安装包
  3. CI 平台:GitHub Actions 自动构建与发版
  4. 仓库平台:GitHub + AtomGit 双仓库

上线后遇到的核心问题:

  1. GitHub 在国内下载 Release 附件速度慢
  2. 用户在 AtomGit 访问项目时,常拿不到与 GitHub 一致的 Tag/Release/附件
  3. 手工同步容易漏步骤,尤其是附件

所以我们要做的不只是“代码镜像”,而是完整发布同步:

  1. 分支同步到 AtomGit
  2. Tag 同步到 AtomGit
  3. Release 元数据同步(标题、正文、预发布状态)
  4. Release 附件同步(安装包等二进制文件)

2. 解决方案设计

2.1 触发方式

工作流支持两种触发:

  1. 自动触发:当上游发布工作流成功后触发(workflow_run
  2. 手动触发:输入指定 tag 执行补同步(workflow_dispatch

2.2 执行流程(自动化)

  1. 解析待同步 Tag
  2. 检查 GitHub Release 是否存在
  3. 拉取全部分支和全部 Tag
  4. 推送分支和 Tag 到 AtomGit
  5. 在 AtomGit 创建或更新同名 Release
  6. 下载 GitHub Release 附件并上传到 AtomGit(已存在同名附件则跳过)

2.3 关键设计点

  1. 幂等:重复执行不会重复上传同名附件
  2. 可补偿:自动失败时可手动指定 Tag 重放
  3. 前置校验:Release 不存在时直接退出,避免误报
  4. 可观测:日志输出下载/上传进度和关键状态

2.4 上传慢问题的处理(重点)

GitHub Runner 直连 AtomGit 上传附件时可能很慢。
AtomGit 官方提供了代理方案,我们在 workflow 里通过修改 /etc/hosts 加速:

  1. 159.138.147.37 api.atomgit.com
  2. 159.138.147.37 file.atomgit.com
  3. 159.138.147.37 file.gitcode.com

这一步是 runner 临时生效,任务结束后自动失效。

GitHub Actions 中通过 hosts 加速 AtomGit 上传(示例)

图:在 workflow 中添加 /etc/hosts 代理解析步骤,用于提升上传稳定性。


3. 前置准备

3.1 AtomGit 仓库准备

先在 AtomGit 创建目标仓库,例如:clickclaw/clickclaw

3.2 AtomGit Token

创建一个具备仓库写入、Release 读写能力的访问令牌,并根据你的安全策略设置到期时间。

令牌创建地址:https://atomgit.com/setting/token-classic

AtomGit 访问令牌创建页面(示例)

3.3 GitHub 仓库 Secrets / Variables

路径:Settings -> Secrets and variables -> Actions
在这里插入图片描述

图:进入仓库设置后,配置 Secrets 。

必填 Secrets
  1. ATOMGIT_TOKEN:AtomGit Token
  2. ATOMGIT_GIT_USERNAME:AtomGit 用户名(Git HTTPS 推送用)
  3. ATOMGIT_REPO_OWNER:AtomGit 仓库 owner(例如 clickclaw
  4. ATOMGIT_REPO_NAME:AtomGit 仓库名(例如 clickclaw
建议 Variables
  1. ATOMGIT_GIT_HOST:默认 atomgit.com
  2. ATOMGIT_API_BASE:默认 https://api.atomgit.com

4. 文件落地路径

在你的仓库添加两个文件:

  1. .github/workflows/sync-atomgit-release.yml
  2. scripts/sync-atomgit-release.js

下面提供完整可复制版本。


5. Workflow 完整代码

文件:.github/workflows/sync-atomgit-release.yml

name: 同步 AtomGit 发布

on:
  workflow_run:
    workflows: ['构建并发布']
    types: [completed]
  workflow_dispatch:
    inputs:
      tag:
        description: '要同步的 release tag,例如 v0.2.1'
        required: true
        type: string

jobs:
  sync:
    if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
    steps:
      - name: 检出触发提交
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.workflow_run.head_sha }}
          fetch-depth: 0
          persist-credentials: false

      - name: 安装 Node.js 22
        uses: actions/setup-node@v6
        with:
          node-version: 22

      - name: 读取版本和标签
        id: meta
        shell: bash
        run: |
          set -euo pipefail
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            TAG="${{ inputs.tag }}"
            VERSION="${TAG#v}"
          else
            VERSION="$(node -p "require('./package.json').version")"
            TAG="v${VERSION}"
          fi
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"

      - name: 确认 GitHub Release 已存在
        id: release_check
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          RELEASE_TAG: ${{ steps.meta.outputs.tag }}
        run: |
          set -euo pipefail
          if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
            echo "GitHub Release ${RELEASE_TAG} 不存在,跳过 AtomGit 同步。"
            echo "exists=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "exists=true" >> "$GITHUB_OUTPUT"

      - name: 拉取完整分支和标签
        if: steps.release_check.outputs.exists == 'true'
        shell: bash
        run: |
          set -euo pipefail
          git fetch origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*'

      - name: 配置 AtomGit /etc/hosts 代理解析
        if: steps.release_check.outputs.exists == 'true'
        shell: bash
        run: |
          set -euo pipefail
          echo "159.138.147.37 api.atomgit.com" | sudo tee -a /etc/hosts
          echo "159.138.147.37 file.atomgit.com" | sudo tee -a /etc/hosts
          echo "159.138.147.37 file.gitcode.com" | sudo tee -a /etc/hosts
          getent hosts api.atomgit.com
          getent hosts file.atomgit.com
          getent hosts file.gitcode.com

      - name: 推送代码和标签到 AtomGit
        if: steps.release_check.outputs.exists == 'true'
        env:
          ATOMGIT_GIT_HOST: ${{ vars.ATOMGIT_GIT_HOST || 'atomgit.com' }}
          ATOMGIT_GIT_OWNER: ${{ vars.ATOMGIT_REPO_OWNER || 'clickclaw' }}
          ATOMGIT_GIT_REPO: ${{ vars.ATOMGIT_REPO_NAME || 'clickclaw' }}
          ATOMGIT_GIT_USERNAME: ${{ secrets.ATOMGIT_GIT_USERNAME }}
          ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }}
        shell: bash
        run: |
          set -euo pipefail

          if [ -z "${ATOMGIT_GIT_OWNER}" ] || [ -z "${ATOMGIT_GIT_REPO}" ] || [ -z "${ATOMGIT_GIT_USERNAME}" ] || [ -z "${ATOMGIT_TOKEN}" ]; then
            echo "缺少 AtomGit Git 同步所需变量或密钥。"
            exit 1
          fi

          REMOTE_URL="https://${ATOMGIT_GIT_USERNAME}:${ATOMGIT_TOKEN}@${ATOMGIT_GIT_HOST}/${ATOMGIT_GIT_OWNER}/${ATOMGIT_GIT_REPO}.git"
          git remote add atomgit "$REMOTE_URL"

          while read -r ref; do
            branch="${ref#refs/remotes/origin/}"
            git push atomgit "${ref}:refs/heads/${branch}"
          done < <(git for-each-ref --format='%(refname)' refs/remotes/origin | grep -v 'refs/remotes/origin/HEAD$')

          git push atomgit 'refs/tags/*:refs/tags/*'

      - name: 同步 AtomGit Release 与附件
        if: steps.release_check.outputs.exists == 'true'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_OWNER: ${{ github.repository_owner }}
          GITHUB_REPO: ${{ github.event.repository.name }}
          RELEASE_TAG: ${{ steps.meta.outputs.tag }}
          ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }}
          ATOMGIT_API_BASE: ${{ vars.ATOMGIT_API_BASE || 'https://api.atomgit.com' }}
          ATOMGIT_OWNER: ${{ vars.ATOMGIT_REPO_OWNER || 'clickclaw'}}
          ATOMGIT_REPO: ${{ vars.ATOMGIT_REPO_NAME || 'clickclaw'}}
        run: node scripts/sync-atomgit-release.js

6. 同步脚本完整代码

文件:scripts/sync-atomgit-release.js

#!/usr/bin/env node

const { Readable } = require('node:stream')

function readCliArgs(argv) {
  const options = {}

  for (let index = 0; index < argv.length; index += 1) {
    const current = argv[index]
    if (current === '--tag') {
      options.tag = argv[index + 1]
      index += 1
      continue
    }

    if (current.startsWith('--tag=')) {
      options.tag = current.slice('--tag='.length)
    }
  }

  return options
}

const cliArgs = readCliArgs(process.argv.slice(2))
if (cliArgs.tag) {
  process.env.RELEASE_TAG = cliArgs.tag
}

if (!process.env.GITHUB_TOKEN && process.env.GH_TOKEN) {
  process.env.GITHUB_TOKEN = process.env.GH_TOKEN
}

const dryRun = ['1', 'true', 'yes'].includes((process.env.DRY_RUN || '').trim().toLowerCase())

const requiredEnv = ['GITHUB_TOKEN', 'RELEASE_TAG']

if (!dryRun) {
  requiredEnv.push('ATOMGIT_TOKEN')
}

for (const name of requiredEnv) {
  if (!process.env[name]) {
    console.error(`[atomgit-sync] missing env: ${name}`)
    process.exit(1)
  }
}

const GITHUB_API_BASE = 'https://api.github.com'
const ATOMGIT_API_BASE = (process.env.ATOMGIT_API_BASE || 'https://api.atomgit.com').replace(
  /\/+$/,
  ''
)

const githubOwner = process.env.GITHUB_OWNER || 'clickclaw'
const githubRepo = process.env.GITHUB_REPO || 'clickclaw'
const releaseTag = process.env.RELEASE_TAG
const atomgitOwner = process.env.ATOMGIT_OWNER || 'clickclaw'
const atomgitRepo = process.env.ATOMGIT_REPO || 'clickclaw'

function logDryRun(message) {
  console.log(`[atomgit-sync][dry-run] ${message}`)
}

function formatBytes(bytes) {
  if (!Number.isFinite(bytes) || bytes < 0) return 'unknown'

  const units = ['B', 'KB', 'MB', 'GB']
  let value = bytes
  let unitIndex = 0

  while (value >= 1024 && unitIndex < units.length - 1) {
    value /= 1024
    unitIndex += 1
  }

  return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}

function createProgressTracker(kind, name, totalBytes) {
  let lastPercentBucket = -1
  let lastLoggedBytes = 0

  return (processedBytes) => {
    if (!Number.isFinite(processedBytes)) return

    if (Number.isFinite(totalBytes) && totalBytes > 0) {
      const percent = Math.min(100, Math.floor((processedBytes / totalBytes) * 100))
      const bucket = percent

      if (bucket > lastPercentBucket) {
        lastPercentBucket = bucket
        console.log(
          `[atomgit-sync] ${kind} ${name}: ${percent}% (${formatBytes(processedBytes)}/${formatBytes(totalBytes)})`
        )
      }
      return
    }

    const step = 5 * 1024 * 1024
    if (processedBytes - lastLoggedBytes >= step) {
      lastLoggedBytes = processedBytes
      console.log(`[atomgit-sync] ${kind} ${name}: ${formatBytes(processedBytes)}`)
    }
  }
}

async function requestJson(url, options = {}, { allow404 = false } = {}) {
  const response = await fetch(url, options)
  if (allow404 && response.status === 404) return null

  if (!response.ok) {
    const text = await response.text()
    if (allow404) {
      try {
        const payload = JSON.parse(text)
        const message = String(payload?.error_message || '')
        if (message.includes('Release Not Found') || message.includes('404')) {
          return null
        }
      } catch {
        // ignore json parse error and throw original response error
      }
    }
    throw new Error(`${options.method || 'GET'} ${url} failed: ${response.status} ${text}`)
  }

  if (response.status === 204) return null
  return response.json()
}

function githubHeaders(extra = {}) {
  return {
    Accept: 'application/vnd.github+json',
    Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
    'X-GitHub-Api-Version': '2022-11-28',
    ...extra,
  }
}

function atomgitHeaders(extra = {}) {
  return {
    Accept: 'application/json',
    Authorization: `Bearer ${process.env.ATOMGIT_TOKEN}`,
    'PRIVATE-TOKEN': process.env.ATOMGIT_TOKEN,
    ...extra,
  }
}

function buildAtomGitApiUrl(path, query = {}) {
  const url = new URL(`${ATOMGIT_API_BASE}${path}`)
  const accessToken = process.env.ATOMGIT_TOKEN

  if (accessToken) {
    url.searchParams.set('access_token', accessToken)
  }

  for (const [key, value] of Object.entries(query)) {
    if (value == null || value === '') continue
    url.searchParams.set(key, String(value))
  }

  return url.toString()
}

async function getGithubRelease() {
  const url = `${GITHUB_API_BASE}/repos/${encodeURIComponent(githubOwner)}/${encodeURIComponent(githubRepo)}/releases/tags/${encodeURIComponent(releaseTag)}`
  return requestJson(url, { headers: githubHeaders() })
}

async function getAtomGitRelease() {
  if (dryRun) {
    logDryRun(`would query AtomGit release by tag: ${releaseTag}`)
    return null
  }

  const url = buildAtomGitApiUrl(
    `/api/v5/repos/${encodeURIComponent(atomgitOwner)}/${encodeURIComponent(atomgitRepo)}/releases/${encodeURIComponent(releaseTag)}`
  )

  return requestJson(url, { headers: atomgitHeaders() }, { allow404: true })
}

function buildReleasePayload(release) {
  return {
    tag_name: release.tag_name,
    target_commitish: release.target_commitish,
    name: release.name,
    body: release.body || '',
    draft: Boolean(release.draft),
    prerelease: Boolean(release.prerelease),
  }
}

async function createOrUpdateAtomGitRelease(githubRelease) {
  const existing = await getAtomGitRelease()
  const payload = buildReleasePayload(githubRelease)

  if (dryRun) {
    logDryRun(
      `${existing ? 'would update' : 'would create'} AtomGit release ${releaseTag} with name "${payload.name || ''}"`
    )
    return {
      tag_name: payload.tag_name,
      name: payload.name,
      body: payload.body,
      assets: [],
    }
  }

  if (!existing) {
    const url = buildAtomGitApiUrl(
      `/api/v5/repos/${encodeURIComponent(atomgitOwner)}/${encodeURIComponent(atomgitRepo)}/releases`
    )

    const created = await requestJson(url, {
      method: 'POST',
      headers: atomgitHeaders({ 'Content-Type': 'application/json' }),
      body: JSON.stringify(payload),
    })

    console.log(`[atomgit-sync] created release ${releaseTag}`)
    return created
  }

  const url = buildAtomGitApiUrl(
    `/api/v5/repos/${encodeURIComponent(atomgitOwner)}/${encodeURIComponent(atomgitRepo)}/releases/${encodeURIComponent(releaseTag)}`
  )

  const updated = await requestJson(url, {
    method: 'PATCH',
    headers: atomgitHeaders({ 'Content-Type': 'application/json' }),
    body: JSON.stringify(payload),
  })

  console.log(`[atomgit-sync] updated release ${releaseTag}`)
  return updated
}

function readUploadUrlPayload(payload) {
  if (!payload) return null
  if (typeof payload === 'string') return payload
  if (typeof payload.upload_url === 'string') return payload.upload_url
  if (typeof payload.url === 'string') return payload.url
  if (typeof payload.href === 'string') return payload.href
  return null
}

function normalizeUploadHeaders(headers) {
  if (!headers || typeof headers !== 'object') return {}

  const normalized = {}
  for (const [key, value] of Object.entries(headers)) {
    if (value == null) continue
    normalized[key] = String(value)
  }
  return normalized
}

async function getAtomGitUploadUrl(fileName) {
  if (dryRun) {
    logDryRun(`would request AtomGit upload url for release ${releaseTag} and file ${fileName}`)
    return {
      url: 'https://example.invalid/upload',
      headers: { 'Content-Type': 'application/octet-stream' },
    }
  }

  const url = buildAtomGitApiUrl(
    `/api/v5/repos/${encodeURIComponent(atomgitOwner)}/${encodeURIComponent(atomgitRepo)}/releases/${encodeURIComponent(releaseTag)}/upload_url`,
    { file_name: fileName }
  )

  const payload = await requestJson(url, { headers: atomgitHeaders() })
  const uploadUrl = readUploadUrlPayload(payload)

  if (!uploadUrl) {
    throw new Error(`unexpected AtomGit upload_url payload: ${JSON.stringify(payload)}`)
  }

  return {
    url: uploadUrl,
    headers: normalizeUploadHeaders(payload.headers),
  }
}

async function downloadGithubAsset(asset) {
  const response = await fetch(asset.url, {
    headers: githubHeaders({ Accept: 'application/octet-stream' }),
    redirect: 'follow',
  })

  if (!response.ok) {
    const text = await response.text()
    throw new Error(`download GitHub asset ${asset.name} failed: ${response.status} ${text}`)
  }

  const totalBytes = Number(response.headers.get('content-length') || asset.size || 0)
  const reportProgress = createProgressTracker('download', asset.name, totalBytes)

  if (!response.body) {
    const arrayBuffer = await response.arrayBuffer()
    const buffer = Buffer.from(arrayBuffer)
    reportProgress(buffer.byteLength)
    return buffer
  }

  const reader = response.body.getReader()
  const chunks = []
  let processedBytes = 0

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = Buffer.from(value)
    chunks.push(chunk)
    processedBytes += chunk.byteLength
    reportProgress(processedBytes)
  }

  if (
    processedBytes > 0 &&
    (!Number.isFinite(totalBytes) || totalBytes <= 0 || processedBytes !== totalBytes)
  ) {
    reportProgress(processedBytes)
  }

  return Buffer.concat(chunks)
}

async function uploadAtomGitAsset(uploadTarget, asset, buffer) {
  if (dryRun) {
    logDryRun(
      `would upload asset ${asset.name} (${buffer.byteLength} bytes) to ${uploadTarget.url}`
    )
    return
  }

  const reportProgress = createProgressTracker('upload', asset.name, buffer.byteLength)
  const baseHeaders = {
    ...uploadTarget.headers,
    'Content-Length': String(buffer.byteLength),
  }

  if (!baseHeaders['Content-Type'] && !baseHeaders['content-type']) {
    baseHeaders['Content-Type'] = asset.content_type || 'application/octet-stream'
  }

  let processedBytes = 0
  const stream = Readable.from(
    (function* generateChunks() {
      const chunkSize = 1024 * 1024
      for (let offset = 0; offset < buffer.byteLength; offset += chunkSize) {
        const chunk = buffer.subarray(offset, Math.min(offset + chunkSize, buffer.byteLength))
        processedBytes += chunk.byteLength
        reportProgress(processedBytes)
        yield chunk
      }
    })()
  )

  const response = await fetch(uploadTarget.url, {
    method: 'PUT',
    headers: baseHeaders,
    body: stream,
    duplex: 'half',
  })

  if (response.ok || response.status === 409) {
    if (response.status === 409) {
      console.log(`[atomgit-sync] asset exists, skipped: ${asset.name}`)
    } else {
      console.log(`[atomgit-sync] uploaded asset: ${asset.name}`)
    }
    return
  }

  const text = await response.text()
  throw new Error(`PUT upload failed for ${asset.name}: ${response.status} ${text}`)
}

function listExistingAssetNames(release) {
  if (!release || !Array.isArray(release.assets)) return new Set()
  return new Set(release.assets.map((asset) => asset?.name).filter(Boolean))
}

async function syncAssets(githubRelease, atomgitRelease) {
  if (!Array.isArray(githubRelease.assets) || githubRelease.assets.length === 0) {
    console.log('[atomgit-sync] no GitHub release assets, skip upload')
    return
  }

  const existingAssetNames = listExistingAssetNames(atomgitRelease)

  for (const asset of githubRelease.assets) {
    if (existingAssetNames.has(asset.name)) {
      console.log(`[atomgit-sync] asset already present, skipped: ${asset.name}`)
      continue
    }

    const uploadTarget = await getAtomGitUploadUrl(asset.name)

    if (dryRun) {
      logDryRun(
        `would sync asset ${asset.name} (${asset.size ?? 'unknown'} bytes) to ${uploadTarget.url}`
      )
      continue
    }

    console.log(`[atomgit-sync] downloading asset: ${asset.name}`)
    const buffer = await downloadGithubAsset(asset)
    await uploadAtomGitAsset(uploadTarget, asset, buffer)
  }
}

async function main() {
  if (dryRun) {
    logDryRun(`starting sync preview for ${githubOwner}/${githubRepo}@${releaseTag}`)
  }

  const githubRelease = await getGithubRelease()
  console.log(`[atomgit-sync] loaded GitHub release ${githubRelease.tag_name}`)

  const atomgitRelease = await createOrUpdateAtomGitRelease(githubRelease)
  await syncAssets(githubRelease, atomgitRelease)

  console.log(`[atomgit-sync] sync completed for ${releaseTag}`)
}

main().catch((error) => {
  console.error(`[atomgit-sync] ${error.stack || error.message}`)
  process.exit(1)
})

7. 首次上线操作步骤(推荐)

  1. 在 AtomGit 创建空仓库
  2. 配置 GitHub Secrets / Variables
  3. 提交上述两个文件
  4. 先在 GitHub 上确认已有一个测试 Release(例如 v0.0.1-test
  5. 手动触发 同步 AtomGit 发布,输入 tag=v0.0.1-test
  6. 查看日志并去 AtomGit 验证结果
    在这里插入图片描述

图:通过 Run workflow 手动补偿触发,输入目标 tag。


8. 验证清单(上线必做)

一次同步成功后,至少检查:

  1. AtomGit 是否出现所有分支(或核心分支)
  2. Tag 是否可见
  3. Release 是否存在且标题/正文正确
  4. 附件是否完整且可下载
  5. 日志是否出现 sync completed for <tag>
    在这里插入图片描述

9. 上线效果与收益

这套流程上线后,收益通常体现在:

  1. 发布一致性提升:GitHub 与 AtomGit 的 Tag/Release/附件更一致
  2. 分发体验提升:国内用户可优先从 AtomGit 下载安装包
  3. 人工成本下降:发版后不再手工重复上传附件
  4. 事故恢复更快:失败时可按 tag 手动补偿重放
  5. 过程更可控:日志可追踪,问题定位更直接

10. 常见问题排查

10.1 提示 GitHub Release xxx 不存在

原因:传入 tag 没有对应 GitHub Release。

处理:

  1. 先在 GitHub 创建该 tag 的 Release
  2. 再手动触发同步

10.2 提示缺少 AtomGit 变量或密钥

原因:Secrets / Variables 未配置完整。

处理:检查:

  1. ATOMGIT_TOKEN
  2. ATOMGIT_GIT_USERNAME
  3. ATOMGIT_REPO_OWNER
  4. ATOMGIT_REPO_NAME

10.3 分支推送认证失败

原因:Git HTTPS 认证不通过。

处理:

  1. ATOMGIT_GIT_USERNAME 是否正确。它通常就是你 AtomGit 个人主页链接中的 path。 例如个人主页是 https://atomgit.com/qq_15502821,则 ATOMGIT_GIT_USERNAME=qq_15502821
  2. Token令牌 是否过期或权限不足

10.4 附件上传慢或不稳定

处理:

  1. 保留 workflow 的 hosts 代理解析步骤
  2. 失败后使用手动触发按 tag 补偿
  3. 大附件场景优先观察日志中的 download/upload 进度

10.5 重跑任务会不会重复上传

不会。脚本按附件文件名去重,AtomGit 已有同名附件会跳过。


11. 运维建议

  1. 建议把同步工作流与发布工作流解耦,便于独立重试
  2. 建议保留手动触发入口,作为稳定补偿通道
  3. 建议固定 Node 版本(示例为 22)避免环境漂移
  4. 建议在发布公告中统一给 AtomGit Release 链接,减少用户路径分叉

12. ClickClaw 实例说明

若你是基于 ClickClaw 方案落地:

  1. 生产 workflow:.github/workflows/sync-atomgit-release.yml
  2. 生产脚本:scripts/sync-atomgit-release.js

你可以直接使用本教程代码,也可以根据自己的上游发布流程调整 workflow_run.workflows 名称。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐