Files
CherryHQ-cherry-studio/.github/workflows/sync-to-gitcode.yml
zhibisora b0c77f6415 fix(ci): improve GitCode sync reliability (#15063)
<!-- Template from
https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Consider creating this PR as draft:
https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
-->

<!--

🚨 Branch Strategy Change (Effective April 3, 2026) 🚨

The `main` branch is now under CODE FREEZE.

- main branch: Only accepts critical bug fixes via `hotfix/*` branches.
Fix PRs must be minimal in scope and must not include any refactoring
code.
- v2 branch: All new features, refactoring, and optimizations should be
submitted to the `v2` branch.

If you are submitting a bug fix to main, please ensure your PR is from a
`hotfix/*` branch.

-->

### What this PR does

Before this PR:

GitCode release sync builds signed Windows artifacts and uploads them to
GitCode in one self-hosted Windows signing job. If the signing runner
has unreliable outbound network connectivity, the GitCode release
creation or asset upload can fail after the signed artifacts were
already built. The workflow also has no dry-run mode for validating a
manual release sync.

After this PR:

The workflow builds signed Windows artifacts on the Windows signing
runner, uploads them as a short-lived GitHub Actions artifact, then
performs GitCode release creation and asset upload from `ubuntu-latest`.
Manual dispatch supports a `dry_run` mode that previews the release
payload and upload file list without creating the GitCode release.
Windows code signing also retries timestamping across multiple timestamp
servers before failing.

<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)`
format, will close the issue(s) when PR gets merged)*: -->

Fixes # None

### Why we need it and why it was done in this way

The following tradeoffs were made:

The release sync now uses an intermediate GitHub Actions artifact to
pass signed Windows files from the signing runner to the Ubuntu sync
job. This adds one artifact upload/download step, but keeps certificate
access constrained to the signing runner while moving GitCode API
traffic to a more reliable hosted runner.

The following alternatives were considered:

Keeping GitCode sync on the signing runner was simpler, but it leaves
release sync vulnerable to transient network failures on that runner.
Retrying only the GitCode upload would not address timestamp-server
flakiness during Windows signing, so this PR also adds timestamp server
fallback and retry support in `scripts/win-sign.js`.

Links to places where the discussion took place: N/A

### Breaking changes

None.

### Special notes for your reviewer

Validation performed:

- `pnpm format`
- `pnpm lint` (passed with one pre-existing unrelated React hook warning
in
`src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx`)
- `pnpm test`
- Parsed `.github/workflows/sync-to-gitcode.yml` with the repository
`yaml` package

`actionlint` was attempted, but the npm package named `actionlint` does
not expose a binary and this environment does not have Go installed to
run the upstream Go tool directly.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [ ] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR
requires additional action from users switching to the new release,
include the string "action required".
2. If no release note is required, just write "NONE".
3. Only include user-facing changes (new features, bug fixes visible to
users, UI changes, behavior changes). For CI, maintenance, internal
refactoring, build tooling, or other non-user-facing work, write "NONE".
-->

```release-note
NONE
```

Signed-off-by: zhibisora <73344387+zhibisora@users.noreply.github.com>
2026-05-14 10:06:56 +08:00

500 lines
17 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@v4
with:
name: signed-windows-artifacts
path: |
dist/*.exe
dist/latest.yml
if-no-files-found: error
retention-days: 1
sync-to-gitcode:
runs-on: ubuntu-latest
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