airi/.github/scripts/publish-gitcode-release.sh
Lovehsigure_520 65751f598d
Some checks are pending
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run
Update Nix pnpmDeps Hash / update (push) Waiting to run
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`.
2026-05-19 21:49:26 +08:00

400 lines
12 KiB
Bash

#!/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 "$@"