From 65751f598deb5f3b884dec4418ad547e831d8fdc Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Tue, 19 May 2026 21:49:26 +0800 Subject: [PATCH] chore(ci): mirror release assets to GitCode (#1797) ## Summary Adds GitCode Release mirroring for AIRI release assets. - Adds a reusable `.github/scripts/publish-gitcode-release.sh` helper that creates or reuses the matching GitCode release, compares GitHub and GitCode asset names, downloads only missing `AIRI-*` and `latest-*.yml` assets, uploads them through GitCode's upload URL API, and verifies the final asset list. - Hooks the mirror step into desktop, Android, and iOS release workflows so assets produced by separate release jobs can converge on the same GitCode release. - Keeps the mirror idempotent so repeated or concurrent release workflows skip already mirrored files. ## Why GitHub Releases are slow for many mainland China users. GitCode provides a domestic fallback release page, but manually copying installers after every release is error-prone and tedious. ## Validation - `bash -n .github/scripts/publish-gitcode-release.sh` - YAML parsing for: - `.github/workflows/release-tamagotchi.yml` - `.github/workflows/release-pocket-android.yml` - `.github/workflows/release-pocket-ios.yml` - `git diff --check -- .github/scripts/publish-gitcode-release.sh .github/workflows/release-tamagotchi.yml .github/workflows/release-pocket-android.yml .github/workflows/release-pocket-ios.yml` - Local idempotency check against existing `v0.10.1` GitHub/GitCode releases: - GitCode release already exists - all mirrored assets already present - final asset verification passed ## Setup Required The target repository needs these GitHub Secrets: - `GITCODE_TOKEN` - `GITCODE_OWNER` - `GITCODE_REPO` For the current GitCode project, `GITCODE_OWNER` should remain `MoeruAI` because GitCode's API and release download paths currently resolve that namespace even though the UI displays `moeru-ai`. --- .github/scripts/publish-gitcode-release.sh | 400 +++++++++++++++++++ .github/workflows/release-pocket-android.yml | 12 + .github/workflows/release-pocket-ios.yml | 24 ++ .github/workflows/release-tamagotchi.yml | 24 ++ 4 files changed, 460 insertions(+) create mode 100644 .github/scripts/publish-gitcode-release.sh diff --git a/.github/scripts/publish-gitcode-release.sh b/.github/scripts/publish-gitcode-release.sh new file mode 100644 index 000000000..6d96bff26 --- /dev/null +++ b/.github/scripts/publish-gitcode-release.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash + +set -euo pipefail + +GITCODE_API_BASE="${GITCODE_API_BASE:-https://api.gitcode.com/api/v5}" +GITHUB_REPOSITORY_NAME="${GITHUB_REPOSITORY:-moeru-ai/airi}" +RELEASE_TAG="${GITCODE_RELEASE_TAG:-${RELEASE_TAG:-${GITHUB_REF_NAME:-}}}" +GITCODE_TARGET_COMMITISH="${GITCODE_TARGET_COMMITISH:-main}" +WORK_DIR="${RUNNER_TEMP:-/tmp}/gitcode-release-${RELEASE_TAG}" +ASSETS_DIR="${WORK_DIR}/assets" + +if [[ -z "${RELEASE_TAG}" ]]; then + echo "::error::Missing release tag. Set GITCODE_RELEASE_TAG or run from a release tag ref." + exit 1 +fi + +# Ensures required environment variables are present before any network call. +require_env() { + local name="$1" + + if [[ -z "${!name:-}" ]]; then + echo "::error::Missing required environment variable: ${name}" + exit 1 + fi +} + +# Encodes a single path component for GitCode query parameters. +urlencode() { + jq -nr --arg value "$1" '$value | @uri' +} + +# Formats a value for curl's config file syntax. +curl_config_value() { + jq -Rr @json <<< "$1" +} + +# Writes a curl config file for GitCode API calls without putting the token in +# the process argument list. +write_gitcode_curl_config() { + local path="$1" + local config_file="$2" + + { + printf 'url = %s\n' "$(curl_config_value "${GITCODE_API_BASE}${path}")" + printf 'url-query = %s\n' "$(curl_config_value "access_token=${GITCODE_TOKEN}")" + } > "${config_file}" + + chmod 600 "${config_file}" +} + +# Reads GitHub release metadata and creates the same GitCode release when absent. +ensure_gitcode_release() { + local github_release_json="${WORK_DIR}/github-release.json" + local gitcode_release_json="${WORK_DIR}/gitcode-release.json" + local response_json="${WORK_DIR}/create-release-response.json" + local request_config="${WORK_DIR}/create-release-request.curlrc" + local http_status + + gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY_NAME}" \ + --json tagName,name,body,isPrerelease \ + > "${github_release_json}" + + jq --arg target_commitish "${GITCODE_TARGET_COMMITISH}" '{ + tag_name: .tagName, + name: .name, + body: .body, + target_commitish: $target_commitish, + release_status: (if .isPrerelease then "pre" else "latest" end) + }' "${github_release_json}" > "${gitcode_release_json}" + + write_gitcode_curl_config "/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases" "${request_config}" + + http_status="$( + curl --retry 3 --retry-all-errors -sS \ + --config "${request_config}" \ + -o "${response_json}" \ + -w '%{http_code}' \ + -X POST \ + -H 'Content-Type: application/json' \ + --data @"${gitcode_release_json}" + )" + + if [[ "${http_status}" =~ ^2 ]]; then + echo "Created GitCode release ${RELEASE_TAG}." + return + fi + + if jq -e '.error_code == 409 or (.error_message // "" | contains("Release already exists"))' "${response_json}" >/dev/null 2>&1; then + echo "GitCode release ${RELEASE_TAG} already exists; reusing it." + return + fi + + echo "::error::Failed to create GitCode release ${RELEASE_TAG}; HTTP ${http_status}." + exit 1 +} + +# Fetches the current GitCode release assets so repeated workflow runs can skip +# files that were already mirrored. +write_existing_gitcode_asset_names() { + local release_json="${WORK_DIR}/gitcode-release-current.json" + local request_config="${WORK_DIR}/get-release-request.curlrc" + local encoded_tag + + encoded_tag="$(urlencode "${RELEASE_TAG}")" + + write_gitcode_curl_config "/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/tags/${encoded_tag}" "${request_config}" + + curl --retry 3 --retry-all-errors -fsS \ + --config "${request_config}" \ + > "${release_json}" + + jq -r '.assets[]?.name' "${release_json}" | sort > "${WORK_DIR}/existing-gitcode-assets.txt" +} + +# Writes the GitHub release assets that should be mirrored to GitCode. +write_expected_github_asset_metadata() { + local release_assets_json="${WORK_DIR}/github-release-assets.json" + local encoded_tag + + encoded_tag="$(urlencode "${RELEASE_TAG}")" + + gh api "repos/${GITHUB_REPOSITORY_NAME}/releases/tags/${encoded_tag}" \ + > "${release_assets_json}" + + jq -r '.assets[].name | select(test("^(AIRI-.*|latest-.*\\.yml)$"))' "${release_assets_json}" \ + | sort \ + > "${WORK_DIR}/expected-asset-names.txt" + + jq -r '.assets[] | select(.name | test("^(AIRI-.*|latest-.*\\.yml)$")) | [.name, (.digest // "")] | @tsv' "${release_assets_json}" \ + | sort \ + > "${WORK_DIR}/expected-asset-digests.tsv" + + if [[ ! -s "${WORK_DIR}/expected-asset-names.txt" ]]; then + echo "::error::No GitHub release assets matched AIRI-* or latest-*.yml." + exit 1 + fi + + if awk -F '\t' '$2 == "" { found = 1 } END { exit found ? 0 : 1 }' "${WORK_DIR}/expected-asset-digests.tsv"; then + echo "::error::GitHub release assets are missing SHA-256 digests; cannot verify GitCode mirror freshness." + exit 1 + fi +} + +# Builds the public GitCode release download URL for an uploaded asset. +gitcode_download_url() { + local filename="$1" + local encoded_filename + local encoded_tag + + encoded_filename="$(urlencode "${filename}")" + encoded_tag="$(urlencode "${RELEASE_TAG}")" + + printf 'https://gitcode.com/%s/%s/releases/download/%s/%s' \ + "${GITCODE_OWNER}" \ + "${GITCODE_REPO}" \ + "${encoded_tag}" \ + "${encoded_filename}" +} + +# Reads a GitCode asset SHA-256 digest by streaming the public download. +read_gitcode_asset_digest() { + local filename="$1" + local download_url + + download_url="$(gitcode_download_url "${filename}")" + + curl --retry 3 --retry-all-errors -fsSL "${download_url}" \ + | sha256sum \ + | awk '{ print "sha256:" $1 }' +} + +# Verifies same-name GitCode assets still match the GitHub release asset digests. +verify_gitcode_asset_digests() { + local mode="${1:-strict}" + local stale_assets="${WORK_DIR}/stale-gitcode-assets.tsv" + local stale_names="${WORK_DIR}/stale-gitcode-asset-names.txt" + local filename + local expected_digest + local actual_digest + + : > "${stale_assets}" + : > "${stale_names}" + + while IFS=$'\t' read -r filename expected_digest; do + if ! grep -Fxq "${filename}" "${WORK_DIR}/existing-gitcode-assets.txt"; then + continue + fi + + if ! actual_digest="$(read_gitcode_asset_digest "${filename}")"; then + echo "::error::Could not read GitCode asset digest for ${filename}." + exit 1 + fi + + if [[ -z "${actual_digest}" ]]; then + echo "::error::Could not read GitCode asset digest for ${filename}." + exit 1 + fi + + if [[ "${actual_digest}" != "${expected_digest}" ]]; then + printf '%s\t%s\t%s\n' "${filename}" "${expected_digest}" "${actual_digest}" >> "${stale_assets}" + printf '%s\n' "${filename}" >> "${stale_names}" + fi + done < "${WORK_DIR}/expected-asset-digests.tsv" + + if [[ -s "${stale_assets}" ]]; then + if [[ "${mode}" == "warn" ]]; then + echo "::warning::GitCode release contains stale mirrored assets; they will be uploaded again:" + else + echo "::error::GitCode release contains stale mirrored assets with digest mismatches:" + fi + + awk -F '\t' '{ printf " - %s: GitHub=%s, GitCode=%s\n", $1, $2, $3 }' "${stale_assets}" + + if [[ "${mode}" == "warn" ]]; then + sort -u -o "${stale_names}" "${stale_names}" + return + fi + + echo "::error::GitCode release still has stale mirrored assets after upload." + exit 1 + fi +} + +# Checks whether an existing GitCode asset should be replaced by the GitHub one. +is_stale_gitcode_asset() { + local filename="$1" + local stale_names="${WORK_DIR}/stale-gitcode-asset-names.txt" + + [[ -s "${stale_names}" ]] && grep -Fxq "${filename}" "${stale_names}" +} + +# Downloads GitHub release assets that are missing from GitCode or stale there. +download_missing_github_release_assets() { + local missing_names="${WORK_DIR}/missing-before-upload.txt" + local stale_names="${WORK_DIR}/stale-gitcode-asset-names.txt" + local upload_names="${WORK_DIR}/asset-names-to-upload.txt" + + rm -rf "${ASSETS_DIR}" + mkdir -p "${ASSETS_DIR}" + + comm -23 "${WORK_DIR}/expected-asset-names.txt" "${WORK_DIR}/existing-gitcode-assets.txt" > "${missing_names}" + { + cat "${missing_names}" + if [[ -s "${stale_names}" ]]; then + cat "${stale_names}" + fi + } | sort -u > "${upload_names}" + + if [[ ! -s "${upload_names}" ]]; then + echo "GitCode release ${RELEASE_TAG} already has every mirrored asset." + : > "${WORK_DIR}/assets-to-upload.txt" + return + fi + + while IFS= read -r filename; do + gh release download "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY_NAME}" \ + --pattern "${filename}" \ + --dir "${ASSETS_DIR}" \ + --clobber + done < "${upload_names}" + + find "${ASSETS_DIR}" -maxdepth 1 -type f -print | sort > "${WORK_DIR}/assets-to-upload.txt" +} + +# Uploads one local asset through GitCode's pre-signed OBS upload URL. +upload_asset() { + local file="$1" + local filename + local encoded_filename + local upload_json + local upload_url + local upload_url_status + local response_body + local http_status + local x_obs_acl + local x_obs_callback + local x_obs_meta_project_id + local x_obs_content_type + local request_config + local encoded_tag + + filename="$(basename "${file}")" + + if grep -Fxq "${filename}" "${WORK_DIR}/existing-gitcode-assets.txt" && ! is_stale_gitcode_asset "${filename}"; then + echo "Skipping existing GitCode asset: ${filename}" + return + fi + + echo "Uploading GitCode asset: ${filename}" + + encoded_filename="$(urlencode "${filename}")" + encoded_tag="$(urlencode "${RELEASE_TAG}")" + upload_json="${WORK_DIR}/upload-${filename}.json" + response_body="${WORK_DIR}/upload-${filename}.response" + request_config="${WORK_DIR}/upload-url-${filename}.curlrc" + + write_gitcode_curl_config "/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${encoded_tag}/upload_url?file_name=${encoded_filename}" "${request_config}" + + upload_url_status="$( + curl --retry 3 --retry-all-errors -sS \ + --config "${request_config}" \ + -o "${upload_json}" \ + -w '%{http_code}' + )" + + if [[ ! "${upload_url_status}" =~ ^2 ]]; then + write_existing_gitcode_asset_names + if grep -Fxq "${filename}" "${WORK_DIR}/existing-gitcode-assets.txt" && ! is_stale_gitcode_asset "${filename}"; then + echo "Skipping concurrently uploaded GitCode asset: ${filename}" + return + fi + + echo "::error::Failed to prepare GitCode upload URL for ${filename}; HTTP ${upload_url_status}." + exit 1 + fi + + { + IFS=$'\t' read -r upload_url x_obs_meta_project_id x_obs_acl x_obs_callback x_obs_content_type + } < <( + jq -r '[ + .url, + .headers["x-obs-meta-project-id"], + .headers["x-obs-acl"], + .headers["x-obs-callback"], + .headers["Content-Type"] + ] | @tsv' "${upload_json}" + ) + + http_status="$( + curl --retry 3 --retry-all-errors -sS \ + -o "${response_body}" \ + -w '%{http_code}' \ + -X PUT \ + -H "x-obs-meta-project-id: ${x_obs_meta_project_id}" \ + -H "x-obs-acl: ${x_obs_acl}" \ + -H "x-obs-callback: ${x_obs_callback}" \ + -H "Content-Type: ${x_obs_content_type}" \ + --upload-file "${file}" \ + "${upload_url}" + )" + + if [[ ! "${http_status}" =~ ^2 ]]; then + write_existing_gitcode_asset_names + if grep -Fxq "${filename}" "${WORK_DIR}/existing-gitcode-assets.txt" && ! is_stale_gitcode_asset "${filename}"; then + echo "GitCode asset appeared after upload retry: ${filename}" + return + fi + + echo "::error::Failed to upload ${filename} to GitCode; HTTP ${http_status}." + exit 1 + fi +} + +# Verifies every downloaded GitHub release asset is visible in the GitCode +# release after upload. +verify_gitcode_release_assets() { + local current_names="${WORK_DIR}/gitcode-asset-names-after-upload.txt" + local missing_names="${WORK_DIR}/missing-gitcode-assets.txt" + + write_existing_gitcode_asset_names + + cp "${WORK_DIR}/existing-gitcode-assets.txt" "${current_names}" + comm -23 "${WORK_DIR}/expected-asset-names.txt" "${current_names}" > "${missing_names}" + + if [[ -s "${missing_names}" ]]; then + echo "::error::GitCode release is missing mirrored assets:" + sed 's/^/ - /' "${missing_names}" + exit 1 + fi + + verify_gitcode_asset_digests + + echo "GitCode release ${RELEASE_TAG} contains all mirrored assets." +} + +# Runs the full GitHub Release to GitCode Release mirror flow. +main() { + require_env GH_TOKEN + require_env GITCODE_TOKEN + require_env GITCODE_OWNER + require_env GITCODE_REPO + + mkdir -p "${WORK_DIR}" + + ensure_gitcode_release + write_existing_gitcode_asset_names + write_expected_github_asset_metadata + verify_gitcode_asset_digests warn + download_missing_github_release_assets + + while IFS= read -r file; do + upload_asset "${file}" + done < "${WORK_DIR}/assets-to-upload.txt" + + verify_gitcode_release_assets +} + +main "$@" diff --git a/.github/workflows/release-pocket-android.yml b/.github/workflows/release-pocket-android.yml index 56a752548..5d9ea710d 100644 --- a/.github/workflows/release-pocket-android.yml +++ b/.github/workflows/release-pocket-android.yml @@ -108,6 +108,7 @@ jobs: if-no-files-found: error - name: Upload to GitHub Releases + id: upload-github-release if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.build_only) }} uses: softprops/action-gh-release@v2 with: @@ -124,3 +125,14 @@ jobs: run: butler push apps/stage-pocket/android/bundle/${{ env.APK_NAME }} nekomeowww/airi:android --userversion ${{ env.VERSION }} env: BUTLER_API_KEY: ${{ secrets.GAME_PUBLISHING_ITCHIO }} + + - name: Publish release assets to GitCode + if: ${{ always() && github.repository == 'moeru-ai/airi' && steps.upload-github-release.outcome == 'success' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) }} + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + GITCODE_OWNER: ${{ secrets.GITCODE_OWNER }} + GITCODE_REPO: ${{ secrets.GITCODE_REPO }} + GITCODE_RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: bash .github/scripts/publish-gitcode-release.sh diff --git a/.github/workflows/release-pocket-ios.yml b/.github/workflows/release-pocket-ios.yml index 09c17fc00..d04fb4f27 100644 --- a/.github/workflows/release-pocket-ios.yml +++ b/.github/workflows/release-pocket-ios.yml @@ -156,3 +156,27 @@ jobs: security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true fi rm -f ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision + + publish-gitcode-release: + name: Publish GitCode Release Mirror + needs: build + if: ${{ always() && github.repository == 'moeru-ai/airi' && needs.build.result == 'success' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.tag != '')) }} + continue-on-error: true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Validate release mirror tools + run: | + gh --version + jq --version + curl --version + + - name: Publish release assets to GitCode + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + GITCODE_OWNER: ${{ secrets.GITCODE_OWNER }} + GITCODE_REPO: ${{ secrets.GITCODE_REPO }} + GITCODE_RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: bash .github/scripts/publish-gitcode-release.sh diff --git a/.github/workflows/release-tamagotchi.yml b/.github/workflows/release-tamagotchi.yml index cda028786..a7510e6bd 100644 --- a/.github/workflows/release-tamagotchi.yml +++ b/.github/workflows/release-tamagotchi.yml @@ -531,6 +531,30 @@ jobs: apps/stage-tamagotchi/bundle/latest-*.yml append_body: true + publish-gitcode-release: + name: Publish GitCode Release Mirror + needs: build + if: ${{ always() && github.repository == 'moeru-ai/airi' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.build_only && !inputs.artifacts_only && inputs.tag != '')) }} + continue-on-error: true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Validate release mirror tools + run: | + gh --version + jq --version + curl --version + + - name: Publish release assets to GitCode + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + GITCODE_OWNER: ${{ secrets.GITCODE_OWNER }} + GITCODE_REPO: ${{ secrets.GITCODE_REPO }} + GITCODE_RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: bash .github/scripts/publish-gitcode-release.sh + publish-distribution: name: Publish Distribution needs: build