Files
CherryHQ-cherry-studio/.github/workflows/sync-to-gitcode.yml
2026-06-05 20:06:14 +08:00

500 lines
18 KiB
YAML

name: Sync Release to GitCode
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v1.0.0)'
required: true
clean:
description: 'Clean node_modules before build'
type: boolean
default: false
dry_run:
description: 'Run all steps except GitCode release creation/upload and Feishu notification'
type: boolean
default: false
permissions:
contents: read
jobs:
build-windows-signed:
runs-on: [self-hosted, windows-signing]
outputs:
tag: ${{ steps.get-tag.outputs.tag }}
steps:
- name: Get tag name
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
fi
- name: Check out Git repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ steps.get-tag.outputs.tag }}
- name: Show checked-out revision
shell: bash
run: |
echo "Checkout ref: ${{ steps.get-tag.outputs.tag }}"
git log -1 --format='%H %s'
- name: Set package.json version
shell: bash
run: |
TAG="${{ steps.get-tag.outputs.tag }}"
VERSION="${TAG#v}"
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
- name: Install pnpm
uses: pnpm/action-setup@v5
- name: Clean node_modules
if: ${{ github.event.inputs.clean == 'true' }}
shell: bash
run: rm -rf node_modules
- name: Install Dependencies
shell: bash
run: pnpm install
- name: Build Windows with code signing
shell: bash
run: pnpm build:win
env:
WIN_SIGN: true
CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }}
CHERRY_CERT_KEY: ${{ secrets.CHERRY_CERT_KEY }}
CHERRY_CERT_CSP: ${{ secrets.CHERRY_CERT_CSP }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: List built Windows artifacts
shell: bash
run: |
echo "Built Windows artifacts:"
ls -la dist/*.exe dist/latest*.yml
- name: Upload signed Windows artifacts
uses: actions/upload-artifact@v7
with:
name: signed-windows-artifacts
path: |
dist/*.exe
dist/latest.yml
if-no-files-found: error
retention-days: 1
sync-to-gitcode:
runs-on: [self-hosted, windows-signing]
needs: build-windows-signed
steps:
- name: Check out Git repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.build-windows-signed.outputs.tag }}
- name: Download GitHub release assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.build-windows-signed.outputs.tag }}
run: |
echo "Downloading release assets for $TAG_NAME..."
mkdir -p release-assets
cd release-assets
# Download all assets from the release
gh release download "$TAG_NAME" \
--repo "${{ github.repository }}" \
--pattern "*" \
--skip-existing
echo "Downloaded GitHub release assets:"
ls -la
- name: Download signed Windows artifacts
uses: actions/download-artifact@v4
with:
name: signed-windows-artifacts
path: signed-windows-artifacts
- name: Replace Windows files with signed versions
shell: bash
run: |
echo "Replacing Windows files with signed versions..."
# Verify signed files exist first
if ! ls signed-windows-artifacts/*.exe 1>/dev/null 2>&1; then
echo "ERROR: No signed .exe files found in signed-windows-artifacts/"
exit 1
fi
if [ ! -f signed-windows-artifacts/latest.yml ]; then
echo "ERROR: No signed latest.yml found in signed-windows-artifacts/"
exit 1
fi
# Remove unsigned Windows files from downloaded assets
rm -f release-assets/*.exe release-assets/latest.yml 2>/dev/null || true
# Copy signed Windows files with error checking
cp signed-windows-artifacts/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; }
cp signed-windows-artifacts/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; }
echo "Final release assets:"
ls -la release-assets/
- name: Get release info
id: release-info
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.build-windows-signed.outputs.tag }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
# Always use gh cli to avoid special character issues
RELEASE_NAME=$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json name -q '.name')
# Use delimiter to safely handle special characters in release name
{
echo 'name<<EOF'
echo "$RELEASE_NAME"
echo 'EOF'
} >> $GITHUB_OUTPUT
# Extract releaseNotes from electron-builder.yml (from releaseNotes: | to end of file, remove 4-space indent)
sed -n '/releaseNotes: |/,$ { /releaseNotes: |/d; s/^ //; p }' electron-builder.yml > release_body.txt
- name: Dry run GitCode release sync
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
shell: bash
env:
GITCODE_OWNER: ${{ vars.GITCODE_OWNER }}
GITCODE_REPO: ${{ vars.GITCODE_REPO }}
GITCODE_API_URL: ${{ vars.GITCODE_API_URL }}
TAG_NAME: ${{ needs.build-windows-signed.outputs.tag }}
RELEASE_NAME: ${{ steps.release-info.outputs.name }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
API_URL="${GITCODE_API_URL:-https://api.gitcode.com/api/v5}"
echo "Dry run enabled. Skipping GitCode release creation and asset upload."
echo "GitCode API URL: $API_URL"
echo "Tag: $TAG_NAME"
echo "Release name: $RELEASE_NAME"
echo "Repo: ${GITCODE_OWNER:-<unset>}/${GITCODE_REPO:-<unset>}"
echo
echo "Release payload preview:"
jq -n \
--arg tag "$TAG_NAME" \
--arg name "$RELEASE_NAME" \
--rawfile body release_body.txt \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: "main"
}'
echo
echo "Files that would be uploaded:"
find release-assets -maxdepth 1 -type f -print | sort
- name: Create GitCode release and upload files
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }}
shell: bash
env:
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
GITCODE_OWNER: ${{ vars.GITCODE_OWNER }}
GITCODE_REPO: ${{ vars.GITCODE_REPO }}
GITCODE_API_URL: ${{ vars.GITCODE_API_URL }}
TAG_NAME: ${{ needs.build-windows-signed.outputs.tag }}
RELEASE_NAME: ${{ steps.release-info.outputs.name }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
# Validate required environment variables
if [ -z "$GITCODE_TOKEN" ]; then
echo "ERROR: GITCODE_TOKEN is not set"
exit 1
fi
if [ -z "$GITCODE_OWNER" ]; then
echo "ERROR: GITCODE_OWNER is not set"
exit 1
fi
if [ -z "$GITCODE_REPO" ]; then
echo "ERROR: GITCODE_REPO is not set"
exit 1
fi
API_URL="${GITCODE_API_URL:-https://api.gitcode.com/api/v5}"
echo "Creating GitCode release..."
echo "Tag: $TAG_NAME"
echo "Repo: $GITCODE_OWNER/$GITCODE_REPO"
# Step 1: Create release
# Use --rawfile to read body directly from file, avoiding shell variable encoding issues
jq -n \
--arg tag "$TAG_NAME" \
--arg name "$RELEASE_NAME" \
--rawfile body release_body.txt \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: "main"
}' > /tmp/release_payload.json
RELEASE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
--connect-timeout 30 --max-time 60 \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
--data-binary "@/tmp/release_payload.json")
HTTP_CODE=$(echo "$RELEASE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$RELEASE_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "Release created successfully"
else
echo "Warning: Release creation returned HTTP $HTTP_CODE"
echo "$RESPONSE_BODY"
exit 1
fi
# Step 2: Upload files to release
echo "Uploading files to GitCode release..."
# Function to upload a single file with retry
upload_file() {
local file="$1"
local filename=$(basename "$file")
local max_retries=3
local retry=0
local curl_status=0
echo "Uploading: $filename"
# URL encode the filename
encoded_filename=$(printf '%s' "$filename" | jq -sRr @uri)
while [ $retry -lt $max_retries ]; do
# Get upload URL
curl_status=0
UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") || curl_status=$?
if [ $curl_status -eq 0 ]; then
UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty')
if [ -n "$UPLOAD_URL" ]; then
# Write headers to temp file to avoid shell escaping issues
echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt
# Upload file using PUT with headers from file
curl_status=0
UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-K /tmp/upload_headers.txt \
--data-binary "@${file}" \
"$UPLOAD_URL") || curl_status=$?
if [ $curl_status -eq 0 ]; then
HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo " Uploaded: $filename"
return 0
else
echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries"
echo " Response: $RESPONSE_BODY"
fi
else
echo " Upload request failed (curl exit $curl_status), retry $((retry + 1))/$max_retries"
fi
else
echo " Failed to get upload URL, retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi
else
echo " Failed to get upload URL (curl exit $curl_status), retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi
retry=$((retry + 1))
[ $retry -lt $max_retries ] && sleep 3
done
echo " Failed: $filename after $max_retries retries"
exit 1
}
# Upload non-yml/json files first
for file in release-assets/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ ! "$filename" =~ \.(yml|yaml|json)$ ]]; then
upload_file "$file"
fi
fi
done
# Upload yml/json files last
for file in release-assets/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ "$filename" =~ \.(yml|yaml|json)$ ]]; then
upload_file "$file"
fi
fi
done
echo "GitCode release sync completed!"
- name: Cleanup temp files
if: always()
shell: bash
run: |
rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt
rm -rf release-assets/
notify:
runs-on: ubuntu-latest
needs: [build-windows-signed, sync-to-gitcode]
if: >-
always() &&
(github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true') &&
(
needs.build-windows-signed.result == 'failure' ||
needs.build-windows-signed.result == 'cancelled' ||
needs.sync-to-gitcode.result == 'failure' ||
needs.sync-to-gitcode.result == 'cancelled'
)
steps:
- name: Send failure notification to Feishu
shell: bash
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
TAG_NAME: ${{ needs.build-windows-signed.outputs.tag }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
BUILD_RESULT: ${{ needs.build-windows-signed.result }}
SYNC_RESULT: ${{ needs.sync-to-gitcode.result }}
run: |
if [ "$BUILD_RESULT" = "cancelled" ] || [ "$SYNC_RESULT" = "cancelled" ]; then
STATUS_TEXT="已取消"
COLOR="orange"
else
STATUS_TEXT="失败"
COLOR="red"
fi
if [ "$BUILD_RESULT" = "failure" ] || [ "$BUILD_RESULT" = "cancelled" ]; then
STAGE="Windows 签名打包"
else
STAGE="同步"
fi
TITLE="GitCode ${STAGE}${STATUS_TEXT}"
DESCRIPTION=$(printf "**标签:** %s\n\n**阶段:** %s\n\n**状态:** %s\n\n**工作流:** [查看详情](%s)" "${TAG_NAME:-unknown}" "$STAGE" "$STATUS_TEXT" "$RUN_URL")
export TITLE DESCRIPTION COLOR
node <<'NODE'
const crypto = require('node:crypto')
const https = require('node:https')
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
const title = process.env.TITLE
const description = process.env.DESCRIPTION
const color = process.env.COLOR
if (!webhookUrl) {
throw new Error('FEISHU_WEBHOOK_URL environment variable is required')
}
if (!secret) {
throw new Error('FEISHU_WEBHOOK_SECRET environment variable is required')
}
const timestamp = Math.floor(Date.now() / 1000)
const sign = crypto.createHmac('sha256', `${timestamp}\n${secret}`).digest('base64')
const payload = JSON.stringify({
timestamp: timestamp.toString(),
sign,
msg_type: 'interactive',
card: {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: description
}
}
],
header: {
template: color,
title: {
tag: 'plain_text',
content: title
}
}
}
})
const url = new URL(webhookUrl)
const req = https.request(
{
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
},
(res) => {
let data = ''
res.on('data', (chunk) => {
data += chunk.toString()
})
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log('Notification sent successfully!')
} else {
console.error(`Feishu API error: ${res.statusCode} - ${data}`)
process.exit(1)
}
})
}
)
req.on('error', (error) => {
console.error(error)
process.exit(1)
})
req.write(payload)
req.end()
NODE