Compare commits

...
5 changed files with 406 additions and 5 deletions
+2 -2
View File
@@ -333,10 +333,10 @@ jobs:
- name: List dist
run: ls -la dist/
- name: Upload to release.picgo.app
- name: Upload update metadata to release.picgo.app
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.test_upload_dist || github.event.inputs.test_upload_dist_to_dev
run: |
ARGS="--all"
ARGS="--all --version-files-only"
if [[ "${{ github.event.inputs.test_upload_dist_to_dev }}" == "true" ]]; then
ARGS="$ARGS --dev"
@@ -0,0 +1,64 @@
name: Mirror Release to AtomGit
on:
push:
branches:
- atomgit-release-ci-test
release:
types:
- published
workflow_dispatch:
inputs:
release_tag:
description: Published GitHub release tag to mirror
required: true
type: string
permissions:
contents: read
concurrency:
group: mirror-atomgit-${{ github.event.release.tag_name || inputs.release_tag }}
cancel-in-progress: false
jobs:
mirror-release:
name: Mirror GitHub Release to AtomGit
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag || 'v3.0.2' }}
ATOM_OWNER: ${{ vars.ATOM_OWNER || 'Molunerfinn' }}
ATOM_REPO: ${{ vars.ATOM_REPO || 'PicGo' }}
ATOM_TARGET_COMMITISH: ${{ vars.ATOM_TARGET_COMMITISH }}
ATOM_UPLOAD_TIMEOUT_MINUTES: ${{ vars.ATOM_UPLOAD_TIMEOUT_MINUTES || '70' }}
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Read GitHub release metadata
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--json tagName,name,body,isDraft,isPrerelease,targetCommitish \
> github-release.json
- name: Download GitHub release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p dist
gh release download "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--dir dist
- name: Publish AtomGit release
env:
ATOM_PAT: ${{ secrets.ATOM_PAT }}
run: node scripts/publish-atomgit-release.js
+335
View File
@@ -0,0 +1,335 @@
const fs = require('fs')
const http = require('http')
const https = require('https')
const path = require('path')
const API_URL = process.env.ATOM_API_URL || 'https://api.atomgit.com/api/v5'
const ATOM_PAT = process.env.ATOM_PAT
const ATOM_OWNER = process.env.ATOM_OWNER
const ATOM_REPO = process.env.ATOM_REPO
const RELEASE_TAG = process.env.RELEASE_TAG
const TARGET_COMMITISH = process.env.ATOM_TARGET_COMMITISH
const DIST_DIR = path.resolve(process.env.DIST_DIR || 'dist')
const RELEASE_METADATA_PATH = path.resolve(process.env.GITHUB_RELEASE_JSON || 'github-release.json')
const MAX_ATTEMPTS = 3
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504])
const UPLOAD_TIMEOUT_MINUTES = Number.parseInt(process.env.ATOM_UPLOAD_TIMEOUT_MINUTES || '70', 10)
function requireEnvironmentVariable(name, value) {
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
}
function encodePathSegment(value) {
return encodeURIComponent(value)
}
function wait(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
function formatFileSize(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDuration(milliseconds) {
return `${Math.round(milliseconds / 1000)}s`
}
async function requestWithRetry(url, createOptions, label) {
let lastError
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url, createOptions())
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === MAX_ATTEMPTS) {
return response
}
await response.text()
console.warn(`[AtomGit] ${label} returned ${response.status}; retrying (${attempt}/${MAX_ATTEMPTS})`)
} catch (error) {
lastError = error
if (attempt === MAX_ATTEMPTS) {
throw error
}
console.warn(`[AtomGit] ${label} failed; retrying (${attempt}/${MAX_ATTEMPTS})`)
}
await wait(1000 * attempt)
}
throw lastError || new Error(`${label} failed after ${MAX_ATTEMPTS} attempts`)
}
function atomGitApiUrl(relativePath) {
return `${API_URL}/repos/${encodePathSegment(ATOM_OWNER)}/${encodePathSegment(ATOM_REPO)}${relativePath}`
}
function atomGitHeaders(additionalHeaders = {}) {
return {
Authorization: `Bearer ${ATOM_PAT}`,
Accept: 'application/json',
...additionalHeaders
}
}
function parseResponseText(text) {
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return text
}
}
async function readResponseBody(response) {
return parseResponseText(await response.text())
}
function formatResponseBody(body) {
return typeof body === 'string' ? body : JSON.stringify(body)
}
async function assertSuccessfulResponse(response, label) {
const body = await readResponseBody(response)
if (!response.ok) {
throw new Error(`${label} failed with HTTP ${response.status}: ${formatResponseBody(body)}`)
}
return body
}
async function verifyCredentials() {
const response = await requestWithRetry(
`${API_URL}/user`,
() => ({ headers: atomGitHeaders() }),
'credential verification'
)
await assertSuccessfulResponse(response, 'AtomGit credential verification')
}
async function getRelease() {
const response = await requestWithRetry(
atomGitApiUrl(`/releases/tags/${encodePathSegment(RELEASE_TAG)}`),
() => ({ headers: atomGitHeaders() }),
`get release ${RELEASE_TAG}`
)
if (response.status === 404) {
await response.text()
return null
}
return assertSuccessfulResponse(response, `Get AtomGit release ${RELEASE_TAG}`)
}
async function createRelease(metadata) {
const name = metadata.name || RELEASE_TAG
const payload = {
tag_name: RELEASE_TAG,
name,
body: metadata.body || `Release ${name}`,
release_status: metadata.isPrerelease ? 'pre' : 'latest'
}
const targetCommitish = TARGET_COMMITISH || metadata.targetCommitish
if (targetCommitish) {
payload.target_commitish = targetCommitish
}
const response = await requestWithRetry(
atomGitApiUrl('/releases'),
() => ({
method: 'POST',
headers: atomGitHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload)
}),
`create release ${RELEASE_TAG}`
)
if (response.status === 409) {
await response.text()
return getRelease()
}
return assertSuccessfulResponse(response, `Create AtomGit release ${RELEASE_TAG}`)
}
async function getOrCreateRelease(metadata) {
const existingRelease = await getRelease()
if (existingRelease) {
console.log(`[AtomGit] Release ${RELEASE_TAG} already exists`)
return existingRelease
}
console.log(`[AtomGit] Creating release ${RELEASE_TAG}`)
const createdRelease = await createRelease(metadata)
if (!createdRelease) {
throw new Error(`AtomGit release ${RELEASE_TAG} was not available after creation`)
}
return createdRelease
}
async function getUploadRequest(fileName) {
const url = new URL(atomGitApiUrl(`/releases/${encodePathSegment(RELEASE_TAG)}/upload_url`))
url.searchParams.set('file_name', fileName)
const response = await requestWithRetry(
url,
() => ({ headers: atomGitHeaders() }),
`request upload URL for ${fileName}`
)
const body = await assertSuccessfulResponse(response, `Get upload URL for ${fileName}`)
if (!body || typeof body !== 'object' || typeof body.url !== 'string' || !body.headers || typeof body.headers !== 'object') {
throw new Error(`AtomGit returned an invalid upload request for ${fileName}`)
}
return body
}
function putAssetBuffer(url, headers, fileBuffer) {
return new Promise((resolve, reject) => {
const targetUrl = new URL(url)
const requestClient = targetUrl.protocol === 'http:' ? http : https
const request = requestClient.request(targetUrl, {
method: 'PUT',
headers: Object.fromEntries(headers.entries())
}, response => {
const chunks = []
response.on('data', chunk => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})
response.on('end', () => {
resolve({
status: response.statusCode || 0,
body: Buffer.concat(chunks).toString('utf8')
})
})
response.on('error', reject)
})
request.setTimeout(UPLOAD_TIMEOUT_MINUTES * 60 * 1000, () => {
request.destroy(new Error(`Upload request timed out after ${UPLOAD_TIMEOUT_MINUTES} minutes`))
})
request.on('error', reject)
request.end(fileBuffer)
})
}
async function uploadAsset(filePath) {
const fileName = path.basename(filePath)
const fileBuffer = await fs.promises.readFile(filePath)
const fileSize = fileBuffer.byteLength
const startedAt = Date.now()
console.log(`[AtomGit] Uploading ${fileName} (${formatFileSize(fileSize)})`)
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const uploadRequest = await getUploadRequest(fileName)
const uploadHeaders = new Headers(uploadRequest.headers)
uploadHeaders.set('Content-Length', String(fileSize))
let response
try {
response = await putAssetBuffer(uploadRequest.url, uploadHeaders, fileBuffer)
} catch (error) {
if (attempt === MAX_ATTEMPTS) {
throw error
}
console.warn(`[AtomGit] Upload ${fileName} failed; requesting a new upload URL (${attempt}/${MAX_ATTEMPTS})`)
await wait(1000 * attempt)
continue
}
if (response.status >= 200 && response.status < 300) {
console.log(`[AtomGit] Uploaded ${fileName} in ${formatDuration(Date.now() - startedAt)}`)
return
}
const responseBody = parseResponseText(response.body)
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === MAX_ATTEMPTS) {
throw new Error(`Upload ${fileName} failed with HTTP ${response.status}: ${formatResponseBody(responseBody)}`)
}
console.warn(`[AtomGit] Upload ${fileName} returned ${response.status}; requesting a new upload URL (${attempt}/${MAX_ATTEMPTS})`)
await wait(1000 * attempt)
}
}
function readReleaseMetadata() {
const metadata = JSON.parse(fs.readFileSync(RELEASE_METADATA_PATH, 'utf8'))
if (metadata.isDraft) {
throw new Error(`GitHub release ${RELEASE_TAG} is still a draft and will not be mirrored`)
}
if (metadata.tagName !== RELEASE_TAG) {
throw new Error(`GitHub release tag ${metadata.tagName} does not match requested tag ${RELEASE_TAG}`)
}
return metadata
}
function findAssets() {
return fs.readdirSync(DIST_DIR, { withFileTypes: true })
.filter(entry => entry.isFile())
.map(entry => path.join(DIST_DIR, entry.name))
.sort((left, right) => left.localeCompare(right))
}
function getExistingAssetNames(release) {
if (!Array.isArray(release.assets)) {
return new Set()
}
return new Set(release.assets
.map(asset => asset && asset.name)
.filter(name => typeof name === 'string'))
}
async function publishRelease() {
requireEnvironmentVariable('ATOM_PAT', ATOM_PAT)
requireEnvironmentVariable('ATOM_OWNER', ATOM_OWNER)
requireEnvironmentVariable('ATOM_REPO', ATOM_REPO)
requireEnvironmentVariable('RELEASE_TAG', RELEASE_TAG)
if (!Number.isInteger(UPLOAD_TIMEOUT_MINUTES) || UPLOAD_TIMEOUT_MINUTES < 1 || UPLOAD_TIMEOUT_MINUTES > 180) {
throw new Error('ATOM_UPLOAD_TIMEOUT_MINUTES must be an integer between 1 and 180')
}
const metadata = readReleaseMetadata()
const assetPaths = findAssets()
if (assetPaths.length === 0) {
throw new Error(`No GitHub release assets found in ${DIST_DIR}`)
}
console.log(`[AtomGit] Mirroring ${assetPaths.length} assets to ${ATOM_OWNER}/${ATOM_REPO}@${RELEASE_TAG}`)
await verifyCredentials()
const release = await getOrCreateRelease(metadata)
const existingAssetNames = getExistingAssetNames(release)
for (const assetPath of assetPaths) {
const fileName = path.basename(assetPath)
if (existingAssetNames.has(fileName)) {
console.log(`[AtomGit] Skipping existing asset ${fileName}`)
continue
}
await uploadAsset(assetPath)
}
console.log(`[AtomGit] Release ${RELEASE_TAG} mirror completed`)
}
publishRelease().catch(error => {
console.error('[AtomGit] Publish failed:', error)
process.exitCode = 1
})
+4 -2
View File
@@ -11,6 +11,7 @@ const distPath = path.join(__dirname, '../dist')
const S3Client = require('@aws-sdk/client-s3').S3Client
const Upload = require('@aws-sdk/lib-storage').Upload
const uploadToDev = process.argv.includes('--dev')
const uploadVersionFilesOnly = process.argv.includes('--version-files-only')
const S3_BUCKET = 'release'
const S3_LEGACY_BUCKET = 'picgo'
@@ -103,6 +104,7 @@ const uploadDist = async () => {
console.log(`[PicGo] Upload mode: ${shouldUploadAll() ? 'ALL PLATFORMS' : process.platform}`)
console.log(`[PicGo] Version: ${VERSION}`)
console.log(`[PicGo] Install package upload: ${uploadVersionFilesOnly ? 'SKIPPED' : 'ENABLED'}`)
console.log(`[PicGo] Total files to upload: ${configs.length}\n`)
const uploadedVersionFiles = new Set()
@@ -117,11 +119,11 @@ const uploadDist = async () => {
console.log(`[${index + 1}/${configs.length}] Processing ${fileName}`)
// 上传构建产物
if (fs.existsSync(filePath)) {
if (!uploadVersionFilesOnly && fs.existsSync(filePath)) {
console.log(` Uploading to S3: ${FILE_PATH}${fileName}`)
await uploadFileToS3(client, S3_BUCKET, `${FILE_PATH}${fileName}`, filePath)
console.log(` ✅ Uploaded: ${fileName}`)
} else {
} else if (!uploadVersionFilesOnly) {
console.warn(` ⚠️ File not found: ${fileName}`)
}
+1 -1
View File
@@ -144,7 +144,7 @@ interface IBrowserWindowOptions {
nodeIntegration: boolean,
nodeIntegrationInWorker: boolean,
contextIsolation: boolean,
backgroundThrottling: boolean
backgroundThrottling?: boolean
webSecurity?: boolean
},
vibrancy?: string | any,