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<> $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:-}/${GITCODE_REPO:-}" 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