From 7641bd3512f4d0d0970a645d51d8bd4d14de068c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 3 Jul 2026 03:04:52 +0800 Subject: [PATCH] feat(release): sync releases to AtomGit --- .github/scripts/sync-atomgit-release.mjs | 321 ++++++++++++++++++ .github/workflows/release.yml | 47 +++ .../workflows/sync-atomgit-release-assets.yml | 62 ++++ 3 files changed, 430 insertions(+) create mode 100644 .github/scripts/sync-atomgit-release.mjs create mode 100644 .github/workflows/sync-atomgit-release-assets.yml diff --git a/.github/scripts/sync-atomgit-release.mjs b/.github/scripts/sync-atomgit-release.mjs new file mode 100644 index 000000000..18bed4db6 --- /dev/null +++ b/.github/scripts/sync-atomgit-release.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +import { basename, join } from "node:path"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const DEFAULT_API_BASE = "https://api.atomgit.com/api/v5"; +const DEFAULT_REPOSITORY = "t8y2/dbx"; + +function parseArgs(argv) { + const args = { + apiBase: process.env.ATOMGIT_API_BASE || DEFAULT_API_BASE, + repository: process.env.ATOMGIT_REPOSITORY || DEFAULT_REPOSITORY, + token: process.env.ATOMGIT_TOKEN || "", + githubReleasePath: "", + assetsDir: "", + skipExistingAssets: true, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--github-release") { + args.githubReleasePath = argv[++i]; + } else if (arg === "--assets-dir") { + args.assetsDir = argv[++i]; + } else if (arg === "--repository") { + args.repository = argv[++i]; + } else if (arg === "--api-base") { + args.apiBase = argv[++i]; + } else if (arg === "--replace-assets") { + args.skipExistingAssets = false; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!args.token) { + throw new Error("ATOMGIT_TOKEN is required."); + } + if (!args.githubReleasePath || !args.assetsDir) { + throw new Error("Usage: sync-atomgit-release.mjs --github-release --assets-dir "); + } + if (!args.repository.includes("/")) { + throw new Error(`Invalid AtomGit repository: ${args.repository}`); + } + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const [owner, repo] = args.repository.split("/", 2); + const release = JSON.parse(readFileSync(args.githubReleasePath, "utf8")); + const tag = release.tagName || release.tag_name; + if (!tag) { + throw new Error("GitHub release JSON is missing tagName."); + } + + const client = new AtomGitClient({ apiBase: args.apiBase, token: args.token, owner, repo }); + const atomRelease = await ensureRelease(client, release); + const existingAssets = atomGitAssetNames(atomRelease); + const assets = localAssets(args.assetsDir); + if (assets.length === 0) { + console.log("No release assets found to sync."); + return; + } + + console.log(`Syncing ${assets.length} asset(s) to AtomGit release ${tag}.`); + for (const assetPath of assets) { + const name = basename(assetPath); + if (args.skipExistingAssets && existingAssets.has(name)) { + console.log(`Skipping existing AtomGit asset: ${name}`); + continue; + } + await uploadAsset(client, tag, assetPath); + } +} + +async function ensureRelease(client, githubRelease) { + const tag = githubRelease.tagName || githubRelease.tag_name; + const payload = { + tag_name: tag, + name: githubRelease.name || tag, + body: githubRelease.body || "", + target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "", + prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease), + }; + + const existing = await client.getRelease(tag); + if (existing) { + console.log(`Updating existing AtomGit release: ${tag}`); + return client.updateRelease(tag, payload); + } + + console.log(`Creating AtomGit release: ${tag}`); + return client.createRelease(payload); +} + +async function uploadAsset(client, tag, filePath) { + const name = basename(filePath); + const size = statSync(filePath).size; + console.log(`Uploading ${name} (${size} bytes)`); + const uploadTarget = await client.getUploadTarget(tag, name); + const contentType = contentTypeFor(name); + const uploadHeaders = uploadTarget.headers.length > 0 + ? uploadTarget.headers + : [ + ["Authorization", `Bearer ${client.token}`], + ["X-Api-Version", "2023-02-21"], + ]; + const contentTypeHeader = headerValue(uploadHeaders, "Content-Type") || contentType; + const curlHeaders = uploadHeaders + .filter(([key]) => key.toLowerCase() !== "content-type") + .flatMap(([key, value]) => ["--header", `${key}: ${value}`]); + + // AtomGit returns a per-file upload URL plus required upload headers. + // Try raw PUT first and fall back to multipart POST for API-compatible deployments. + const put = runCurl([ + "--fail-with-body", + "--location", + "--show-error", + "--retry", + "3", + "--retry-delay", + "5", + "--request", + "PUT", + ...curlHeaders, + "--header", + `Content-Type: ${contentTypeHeader}`, + "--upload-file", + filePath, + uploadTarget.url, + ]); + if (put.status === 0) { + return; + } + + console.warn(`PUT upload failed for ${name}; retrying as multipart POST.`); + const post = runCurl([ + "--fail-with-body", + "--location", + "--show-error", + "--retry", + "3", + "--retry-delay", + "5", + "--request", + "POST", + ...curlHeaders, + "--form", + `file=@${filePath};filename=${name};type=${contentTypeHeader}`, + uploadTarget.url, + ]); + if (post.status !== 0) { + throw new Error(`Failed to upload ${name} to AtomGit.`); + } +} + +function runCurl(args) { + return spawnSync("curl", args, { stdio: "inherit" }); +} + +function localAssets(dir) { + return readdirSync(dir) + .map((name) => join(dir, name)) + .filter((path) => statSync(path).isFile()) + .sort((a, b) => statSync(a).size - statSync(b).size || basename(a).localeCompare(basename(b))); +} + +function atomGitAssetNames(release) { + const names = new Set(); + const groups = [ + release?.assets, + release?.attach_files, + release?.attachFiles, + release?.files, + release?.attachments, + ]; + for (const group of groups) { + if (!Array.isArray(group)) { + continue; + } + for (const item of group) { + const name = item?.name || item?.file_name || item?.filename || item?.title; + if (name) { + names.add(name); + } + } + } + return names; +} + +function contentTypeFor(name) { + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".zip")) return "application/zip"; + if (name.endsWith(".gz") || name.endsWith(".tgz")) return "application/gzip"; + if (name.endsWith(".dmg")) return "application/x-apple-diskimage"; + if (name.endsWith(".exe") || name.endsWith(".msi")) return "application/octet-stream"; + if (name.endsWith(".deb")) return "application/vnd.debian.binary-package"; + if (name.endsWith(".rpm")) return "application/x-rpm"; + if (name.endsWith(".AppImage")) return "application/octet-stream"; + return "application/octet-stream"; +} + +function headerValue(headers, name) { + const normalizedName = name.toLowerCase(); + const entry = headers.find(([key]) => key.toLowerCase() === normalizedName); + return entry ? entry[1] : ""; +} + +class AtomGitClient { + constructor({ apiBase, token, owner, repo }) { + this.apiBase = apiBase.replace(/\/+$/, ""); + this.token = token; + this.owner = owner; + this.repo = repo; + } + + async getRelease(tag) { + const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, null, { + allow404: true, + }); + if (res.status === 404) { + return null; + } + return parseJsonResponse(res); + } + + async createRelease(payload) { + const res = await this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, payload, { + acceptStatuses: [409, 422], + }); + if (res.status === 409 || res.status === 422) { + return this.updateRelease(payload.tag_name, payload); + } + return parseJsonResponse(res); + } + + async updateRelease(tag, payload) { + const res = await this.request("PATCH", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, payload); + return parseJsonResponse(res); + } + + async getUploadTarget(tag, fileName) { + const params = new URLSearchParams({ file_name: fileName }); + const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}/upload_url?${params}`); + const body = await parseJsonResponse(res); + if (body && typeof body === "object" && !Array.isArray(body)) { + console.log(`AtomGit upload_url response keys: ${Object.keys(body).sort().join(", ")}`); + if (body.headers && typeof body.headers === "object") { + console.log(`AtomGit upload header keys: ${Object.keys(body.headers).sort().join(", ")}`); + } + } + const uploadUrl = typeof body === "string" ? body : body.url || body.upload_url || body.uploadUrl; + if (!uploadUrl) { + throw new Error(`AtomGit upload_url response did not include an upload URL: ${JSON.stringify(body)}`); + } + console.log(`AtomGit upload target: ${sanitizeUrlForLog(uploadUrl)}`); + return { + url: uploadUrl, + headers: normalizeUploadHeaders(typeof body === "string" ? null : body.headers), + }; + } + + async request(method, path, body = null, requestOptions = {}) { + const headers = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + "X-Api-Version": "2023-02-21", + }; + const fetchOptions = { method, headers }; + if (body) { + const cleanBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== "")); + headers["Content-Type"] = "application/json"; + fetchOptions.body = JSON.stringify(cleanBody); + } + + const res = await fetch(`${this.apiBase}${path}`, fetchOptions); + const acceptedStatus = requestOptions.acceptStatuses?.includes(res.status); + if (!res.ok && !(requestOptions.allow404 && res.status === 404) && !acceptedStatus) { + const text = await res.text(); + throw new Error(`AtomGit API ${method} ${path} failed with ${res.status}: ${text}`); + } + return res; + } +} + +async function parseJsonResponse(res) { + const text = await res.text(); + if (!text.trim()) { + return {}; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function normalizeUploadHeaders(headers) { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return []; + } + return Object.entries(headers) + .filter(([key, value]) => key && value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, String(value)]); +} + +function sanitizeUrlForLog(value) { + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`; + } catch { + return ""; + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a589230c..7def676b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -375,6 +375,53 @@ jobs: --target-repo "https://cnb.cool/${CNB_REPOSITORY}.git" \ --tags "${TAG_NAME}" + sync-release-to-atomgit: + name: Sync release to AtomGit + needs: publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Ensure AtomGit tag exists + env: + TAG_NAME: ${{ github.ref_name }} + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + run: | + set -euo pipefail + git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + + - name: Download GitHub release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" \ + --clobber + + - name: Sync release assets to AtomGit + env: + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + ATOMGIT_REPOSITORY: "t8y2/dbx" + run: | + set -euo pipefail + node .github/scripts/sync-atomgit-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets" + docker: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/sync-atomgit-release-assets.yml b/.github/workflows/sync-atomgit-release-assets.yml new file mode 100644 index 000000000..4c6e87c58 --- /dev/null +++ b/.github/workflows/sync-atomgit-release-assets.yml @@ -0,0 +1,62 @@ +name: Sync AtomGit Release Assets + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to sync" + required: true + type: string + +permissions: + contents: read + +env: + ATOMGIT_REPOSITORY: "t8y2/dbx" + +jobs: + sync-release-to-atomgit: + name: Sync release to AtomGit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Ensure AtomGit tag exists + env: + TAG_NAME: ${{ inputs.tag }} + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + run: | + set -euo pipefail + git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + + - name: Download GitHub release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ inputs.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" \ + --clobber + + - name: Sync release assets to AtomGit + env: + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + ATOMGIT_REPOSITORY: ${{ env.ATOMGIT_REPOSITORY }} + run: | + set -euo pipefail + node .github/scripts/sync-atomgit-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets"