From 76fc37fee4b07abfde638c85facbf28c014a14b5 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Wed, 1 Jul 2026 21:48:47 +0800 Subject: [PATCH] feat(release): publish into xlings ecosystem (mirror xlings-res/mcpp + index bump PR) Add a publish-ecosystem job (needs all 4 platform builds) that, on release: - mirrors mcpp binaries to xlings-res/mcpp (GitHub + GitCode) via mirror_res.sh so XLINGS_RES downloads resolve on every platform incl. the CN/GitCode path (replaces the previous by-hand mirror); - opens a PR against openxlings/xim-pkgindex bumping mcpp to this version via bump_index.sh (a maintainer merges it). Scripts are vendored under .github/tools/ (mirror_res.sh, bump_index.sh, gtc), kept in sync with openxlings/xlings tools/. Best-effort/non-blocking: failures here never fail the release. Secrets already configured (XLINGS_RES_TOKEN, XIM_PKGINDEX_TOKEN, GITCODE_TOKEN). --- .github/tools/bump_index.sh | 78 +++++++++++ .github/tools/gtc | 243 ++++++++++++++++++++++++++++++++++ .github/tools/mirror_res.sh | 94 +++++++++++++ .github/workflows/release.yml | 42 ++++++ 4 files changed, 457 insertions(+) create mode 100755 .github/tools/bump_index.sh create mode 100755 .github/tools/gtc create mode 100755 .github/tools/mirror_res.sh diff --git a/.github/tools/bump_index.sh b/.github/tools/bump_index.sh new file mode 100755 index 0000000..c05538c --- /dev/null +++ b/.github/tools/bump_index.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Open a PR against openxlings/xim-pkgindex that bumps to . +# +# The actual lua edit is delegated to the index repo's OWN +# `.github/scripts/version-check.py --apply --only `, so this script +# stays agnostic to each package's contract: +# - res_versioned packages (e.g. xlings): appends `[""] = "XLINGS_RES"`. +# - url_template packages (e.g. mcpp): downloads the artifact, computes +# sha256, appends `[""] = { url, sha256 }`. +# and always bumps `["latest"].ref`. Existing version entries are untouched. +# +# This opens a PR only — a maintainer reviews and merges it (index git source +# is intentionally NOT on the release critical path; availability is already +# guaranteed by the xlings-res binary mirror + index artifact). On merge, the +# index repo republishes its artifact + gitee-sync picks it up. +# +# Usage: tools/bump_index.sh # e.g. mcpp 0.0.82 +# Auth: +# PKGINDEX_TOKEN (required) — write to openxlings/xim-pkgindex (clone/push/PR) +# GITHUB_TOKEN (optional) — GitHub API rate headroom + artifact download +# for url_template packages (version-check.py) +set -euo pipefail + +PROJ="${1:?usage: bump_index.sh }" +VER="${2:?usage: bump_index.sh }" +INDEX_REPO="${INDEX_REPO:-openxlings/xim-pkgindex}" +BRANCH="bump/${PROJ}-${VER}" +TOKEN="${PKGINDEX_TOKEN:?PKGINDEX_TOKEN required (write to $INDEX_REPO)}" + +info() { echo "[bump] $*"; } +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT + +info "cloning $INDEX_REPO" +git clone -q --depth 20 "https://x-access-token:${TOKEN}@github.com/${INDEX_REPO}.git" "$WORK/idx" +cd "$WORK/idx" +git config user.email "ci@xlings.dev" +git config user.name "xlings-ci" +git checkout -q -B "$BRANCH" + +info "version-check.py --apply --only $PROJ" +python3 .github/scripts/version-check.py --apply --only "$PROJ" > "$WORK/report.json" || { + echo "[bump] version-check failed:" >&2; cat "$WORK/report.json" >&2 2>/dev/null || true; exit 1; +} + +if git diff --quiet; then + info "no change — index already tracks the latest $PROJ; nothing to PR" + exit 0 +fi + +# Sanity: the resulting latest.ref should equal the released version. A +# mismatch usually means GitHub's releases/latest hasn't caught up yet (API +# lag) or a newer release exists — warn but still open the PR. +GOT="$(grep -oE 'ref = "[^"]+"' pkgs/*/"${PROJ}".lua 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)" +if [[ -n "$GOT" && "$GOT" != "$VER" ]]; then + echo "[bump] WARN: bumped latest.ref to '$GOT' but release version is '$VER' (upstream 'latest' differs?)" +fi + +git add -A +git commit -qm "bump(${PROJ}): track ${VER} as latest + +Auto-generated by the ${PROJ} release pipeline (bump_index.sh). +Applied via version-check.py --apply --only ${PROJ}." + +info "pushing $BRANCH (force — bot branch)" +git push -q -f "https://x-access-token:${TOKEN}@github.com/${INDEX_REPO}.git" "$BRANCH" + +# Open PR — idempotent: a force-push updates an already-open PR in place. +export GH_TOKEN="$TOKEN" +if gh pr view "$BRANCH" -R "$INDEX_REPO" >/dev/null 2>&1; then + info "PR already open for $BRANCH (updated by force-push)" +else + gh pr create -R "$INDEX_REPO" --base main --head "$BRANCH" \ + --title "bump(${PROJ}): track ${VER} as latest" \ + --body "Automated by the ${PROJ} release pipeline. Bumps \`pkgs/*/${PROJ}.lua\` \`latest.ref\` to \`${VER}\` and appends the new version entry (via \`version-check.py --apply --only ${PROJ}\`). + +Merge to publish into the official index — the index repo then republishes its artifact and gitee-sync mirrors the change. Binaries are already live on \`xlings-res/${PROJ}\` (GitHub + GitCode)." + info "PR opened" +fi diff --git a/.github/tools/gtc b/.github/tools/gtc new file mode 100755 index 0000000..4e96d44 --- /dev/null +++ b/.github/tools/gtc @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""gtc — gitcode tool + +Subcommands: + repo create / [--description STR] [--private] + repo push / [--branch BRANCH] + release create / --tag TAG [--name NAME] [--body-file FILE] [--target main] [--prerelease] + release upload / --tag TAG + release publish / --tag TAG [--name NAME] [--body-file FILE] [--target main] [--prerelease] [--asset FILE]... + pr create / --title T --head BRANCH --base BRANCH [--body-file FILE] + +Token / config: + ~/.config/gitcode-tool/config.json → {"token": "...", "api_base": "https://api.gitcode.com/api/v5"} + override: --token TOKEN or GITCODE_TOKEN env var +""" +import argparse +import json +import os +import subprocess +import sys +import urllib.parse +import urllib.request +import urllib.error +from pathlib import Path + +CONFIG_PATH = Path.home() / ".config" / "gitcode-tool" / "config.json" + + +def load_config(): + cfg = {"api_base": "https://api.gitcode.com/api/v5"} + if CONFIG_PATH.is_file(): + cfg.update(json.loads(CONFIG_PATH.read_text())) + cfg["token"] = os.environ.get("GITCODE_TOKEN") or cfg.get("token") or "" + return cfg + + +def http(method, url, *, token, headers=None, json_body=None, data=None, expect=(200, 201)): + h = {"PRIVATE-TOKEN": token, "Accept": "application/json"} + if json_body is not None: + h["Content-Type"] = "application/json" + body = json.dumps(json_body).encode() + elif data is not None: + body = data + else: + body = None + if headers: + h.update(headers) + req = urllib.request.Request(url, data=body, headers=h, method=method) + try: + with urllib.request.urlopen(req, timeout=120) as r: + raw = r.read() + return r.status, raw + except urllib.error.HTTPError as e: + raw = e.read() + if e.code in expect: + return e.code, raw + # gitcode often returns HTTP 400 with a different error_code in the body + try: + j = json.loads(raw) + body_code = j.get("error_code") + msg = (j.get("error_message") or "").lower() + if 422 in expect and ("already exists" in msg or body_code == 422): + return 422, raw + # gitcode may wrap 404 inside HTTP 400 + if 404 in expect and (body_code == 404 or "not found" in msg): + return 404, raw + except Exception: + pass + sys.stderr.write(f"HTTP {e.code} {url}\n{raw.decode(errors='replace')[:500]}\n") + sys.exit(2) + + +def api_json(cfg, method, path, **kwargs): + url = cfg["api_base"] + path + status, raw = http(method, url, token=cfg["token"], **kwargs) + if not raw: + return status, None + try: + return status, json.loads(raw) + except json.JSONDecodeError: + return status, raw.decode(errors="replace") + + +# ─────────────── repo ─────────────── + +def cmd_repo_create(args, cfg): + owner, name = args.repo.split("/", 1) + payload = {"name": name, "path": name, "description": args.description or "", "private": args.private} + # personal user repos: POST /user/repos ; org repos: POST /orgs/:org/repos + me_status, me = api_json(cfg, "GET", "/user") + if me_status == 200 and isinstance(me, dict) and me.get("login") == owner: + path = "/user/repos" + else: + path = f"/orgs/{owner}/repos" + status, body = api_json(cfg, "POST", path, json_body=payload, expect=(200, 201, 422)) + if status == 422: + print(f"warn: repo {owner}/{name} may already exist") + return + print(f"created {body.get('full_name', owner+'/'+name)} → {body.get('html_url') or body.get('url')}") + + +def cmd_repo_push(args, cfg): + owner, name = args.repo.split("/", 1) + local = Path(args.local_dir).resolve() + if not local.is_dir(): + sys.exit(f"local_dir not found: {local}") + branch = args.branch or "main" + user = api_json(cfg, "GET", "/user")[1] + login = user.get("login") if isinstance(user, dict) else owner + remote_url = f"https://{login}:{cfg['token']}@gitcode.com/{owner}/{name}.git" + + def run(*cmd): + subprocess.run(cmd, cwd=local, check=True) + + if not (local / ".git").is_dir(): + run("git", "init", "-b", branch) + # ensure remote + r = subprocess.run(["git", "-C", str(local), "remote"], capture_output=True, text=True) + remotes = r.stdout.split() + if "gitcode" in remotes: + run("git", "remote", "set-url", "gitcode", remote_url) + else: + run("git", "remote", "add", "gitcode", remote_url) + # commit any pending + subprocess.run(["git", "-C", str(local), "add", "-A"], check=True) + diff = subprocess.run(["git", "-C", str(local), "diff", "--cached", "--quiet"]).returncode + if diff != 0: + run("git", "commit", "-m", args.message or "init") + run("git", "push", "-u", "gitcode", branch) + print(f"pushed → https://gitcode.com/{owner}/{name}") + + +# ─────────────── release ─────────────── + +def _release_payload(args): + if args.body_file: + body = Path(args.body_file).read_text().strip() + else: + body = "" + if not body: + body = args.name or args.tag + return { + "tag_name": args.tag, + "name": args.name or args.tag, + "body": body, + "target_commitish": args.target or "main", + "prerelease": bool(args.prerelease), + } + + +def cmd_release_create(args, cfg): + payload = _release_payload(args) + # check existing first to make this idempotent + s, _ = api_json(cfg, "GET", f"/repos/{args.repo}/releases/tags/{args.tag}", expect=(200, 404)) + if s == 200: + print(f"release {args.repo}@{args.tag} already exists, skipping") + return + status, body = api_json(cfg, "POST", f"/repos/{args.repo}/releases", json_body=payload, + expect=(200, 201, 422)) + if status == 422: + print(f"warn: release {args.tag} may already exist on {args.repo}") + return + print(f"release created: {args.repo}@{args.tag}") + + +def _upload_one(cfg, repo, tag, file_path): + fname = Path(file_path).name + url = f"{cfg['api_base']}/repos/{repo}/releases/{tag}/upload_url?file_name={urllib.parse.quote(fname)}" + status, info = http("GET", url, token=cfg["token"]) + info = json.loads(info) + put_url = info["url"] + h = info["headers"] + with open(file_path, "rb") as f: + data = f.read() + req = urllib.request.Request(put_url, data=data, headers=h, method="PUT") + with urllib.request.urlopen(req, timeout=600) as r: + ok = r.status == 200 + body = r.read().decode(errors="replace")[:200] + if not ok: + sys.exit(f"upload {fname} failed: {body}") + print(f" uploaded: {fname} ({len(data)} bytes)") + + +def cmd_release_upload(args, cfg): + for f in args.files: + _upload_one(cfg, args.repo, args.tag, f) + + +def cmd_release_publish(args, cfg): + cmd_release_create(args, cfg) + if args.asset: + for f in args.asset: + _upload_one(cfg, args.repo, args.tag, f) + + +# ─────────────── pull request ─────────────── + +def cmd_pr_create(args, cfg): + body = "" + if args.body_file: + body = Path(args.body_file).read_text() + payload = {"title": args.title, "head": args.head, "base": args.base, "body": body} + status, resp = api_json(cfg, "POST", f"/repos/{args.repo}/pulls", json_body=payload) + print(f"PR created: {resp.get('html_url') or resp}") + + +# ─────────────── argparse ─────────────── + +def build_parser(): + p = argparse.ArgumentParser(prog="gtc") + p.add_argument("--token", help="override token") + sub = p.add_subparsers(dest="cmd", required=True) + + rp = sub.add_parser("repo").add_subparsers(dest="op", required=True) + rc = rp.add_parser("create"); rc.add_argument("repo"); rc.add_argument("--description"); rc.add_argument("--private", action="store_true"); rc.set_defaults(_fn=cmd_repo_create) + rs = rp.add_parser("push"); rs.add_argument("repo"); rs.add_argument("local_dir"); rs.add_argument("--branch"); rs.add_argument("-m","--message"); rs.set_defaults(_fn=cmd_repo_push) + + re = sub.add_parser("release").add_subparsers(dest="op", required=True) + for name, fn in (("create", cmd_release_create), ("publish", cmd_release_publish)): + x = re.add_parser(name); x.add_argument("repo"); x.add_argument("--tag", required=True); x.add_argument("--name"); x.add_argument("--body-file"); x.add_argument("--target"); x.add_argument("--prerelease", action="store_true") + if name == "publish": + x.add_argument("--asset", action="append", default=[]) + x.set_defaults(_fn=fn) + ru = re.add_parser("upload"); ru.add_argument("repo"); ru.add_argument("--tag", required=True); ru.add_argument("files", nargs="+"); ru.set_defaults(_fn=cmd_release_upload) + + pr = sub.add_parser("pr").add_subparsers(dest="op", required=True) + pc = pr.add_parser("create"); pc.add_argument("repo"); pc.add_argument("--title", required=True); pc.add_argument("--head", required=True); pc.add_argument("--base", required=True); pc.add_argument("--body-file"); pc.set_defaults(_fn=cmd_pr_create) + + return p + + +def main(): + args = build_parser().parse_args() + cfg = load_config() + if args.token: + cfg["token"] = args.token + if not cfg["token"]: + sys.exit("no token: set GITCODE_TOKEN env or write to ~/.config/gitcode-tool/config.json") + args._fn(args, cfg) + + +if __name__ == "__main__": + main() diff --git a/.github/tools/mirror_res.sh b/.github/tools/mirror_res.sh new file mode 100755 index 0000000..4309538 --- /dev/null +++ b/.github/tools/mirror_res.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Mirror a project's release binaries from its upstream GitHub release to the +# xlings-res/ resource repo on GitHub AND GitCode, so `XLINGS_RES` +# downloads (esp. the CN path, which is GitCode-only for package binaries) +# resolve for ALL platforms. +# +# Generic over — currently `xlings` and `mcpp`. Tag scheme on +# xlings-res/ is the BARE version (e.g. 0.4.63 / 0.0.82); the upstream +# source tag is v. Asset filenames are identical on both ends (the +# xlings-res convention `--.` matches upstream). +# +# Usage: tools/mirror_res.sh # e.g. mcpp 0.0.82 +# Auth: XLINGS_RES_TOKEN (github write to xlings-res), GITCODE_TOKEN (+ gtc on PATH) +# Env: SRC_REPO / GH_DST / GTC_DST / ASSETS (space-separated) override the +# per-project defaults below. +set -euo pipefail + +PROJ="${1:?usage: mirror_res.sh }" +VER="${2:?usage: mirror_res.sh }" + +# ── Per-project defaults (source repo + platform asset list) ────── +case "$PROJ" in + xlings) + : "${SRC_REPO:=openxlings/xlings}" + DEFAULT_ASSETS="xlings-${VER}-linux-x86_64.tar.gz xlings-${VER}-linux-aarch64.tar.gz xlings-${VER}-macosx-arm64.tar.gz xlings-${VER}-windows-x86_64.zip" + ;; + mcpp) + : "${SRC_REPO:=mcpp-community/mcpp}" + # mcpp ships a .sha256 sidecar next to each archive; mirror both so the + # xlings-res/mcpp release stays byte-for-byte equivalent to upstream. + p="mcpp-${VER}" + DEFAULT_ASSETS="${p}-linux-x86_64.tar.gz ${p}-linux-x86_64.tar.gz.sha256 ${p}-linux-aarch64.tar.gz ${p}-linux-aarch64.tar.gz.sha256 ${p}-macosx-arm64.tar.gz ${p}-macosx-arm64.tar.gz.sha256 ${p}-windows-x86_64.zip ${p}-windows-x86_64.zip.sha256" + ;; + *) + echo "[mirror] unknown project '$PROJ' (expected xlings|mcpp)" >&2 + exit 2 + ;; +esac +: "${GH_DST:=xlings-res/$PROJ}" +: "${GTC_DST:=xlings-res/$PROJ}" +read -r -a ASSETS <<< "${ASSETS:-$DEFAULT_ASSETS}" + +info() { echo "[mirror] $*"; } + +DL="$(mktemp -d)"; trap 'rm -rf "$DL"' EXIT + +info "downloading $SRC_REPO v$VER assets ($PROJ)" +for a in "${ASSETS[@]}"; do + gh release download "v$VER" -R "$SRC_REPO" -D "$DL" -p "$a" 2>/dev/null || { echo "[mirror] FAIL: missing $a in $SRC_REPO v$VER" >&2; exit 1; } +done + +# ── GitHub (gh --clobber, reliable) ─────────────────────────────── +if [[ -n "${XLINGS_RES_TOKEN:-}" ]] || gh auth status >/dev/null 2>&1; then + info "GitHub $GH_DST tag $VER" + GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release view "$VER" -R "$GH_DST" >/dev/null 2>&1 \ + || GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release create "$VER" -R "$GH_DST" --title "$VER" --notes "$PROJ $VER (mirror of $SRC_REPO)" + for a in "${ASSETS[@]}"; do + GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release upload "$VER" "$DL/$a" -R "$GH_DST" --clobber + done +else + info "no github auth; skipping github mirror" +fi + +# ── GitCode (gtc, per-file retry — multi-file upload can 502 and drop files) ── +if [[ -n "${GITCODE_TOKEN:-}" ]] && command -v gtc >/dev/null 2>&1; then + info "GitCode $GTC_DST tag $VER" + gtc release create "$GTC_DST" --tag "$VER" --name "$VER" 2>/dev/null || true + # Upload then verify the actual DOWNLOAD is 200 (gtc can report success yet + # leave a phantom/missing asset — obs_callback flakiness), retry up to 5. + for a in "${ASSETS[@]}"; do + for try in 1 2 3 4 5; do + gtc release upload "$GTC_DST" "$DL/$a" --tag "$VER" >/dev/null 2>&1 || true + if [[ "$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://gitcode.com/${GTC_DST}/releases/download/${VER}/${a}" 2>/dev/null)" == 200 ]]; then + break + fi + echo "[mirror] gtc $a not 200 after try $try, retrying..."; sleep 4 + done + done +else + info "no GITCODE_TOKEN/gtc; skipping gitcode mirror" +fi + +# ── Verify every platform on both hosts ─────────────────────────── +info "verify:" +rc=0 +for host in "github.com/$GH_DST" "gitcode.com/$GTC_DST"; do + for a in "${ASSETS[@]}"; do + code=$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://${host}/releases/download/${VER}/${a}" 2>/dev/null || echo ERR) + echo " $code https://${host}/releases/download/${VER}/${a}" + [[ "$code" == 200 ]] || rc=1 + done +done +[[ $rc == 0 ]] && info "all platforms mirrored OK" || { echo "[mirror] WARN: some assets not 200" >&2; } +exit $rc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d1a5af..d6fb0fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -695,3 +695,45 @@ jobs: dist/mcpp-${{ steps.resolve.outputs.version }}-windows-x86_64.zip.sha256 dist/mcpp-windows-x86_64.zip dist/mcpp-windows-x86_64.zip.sha256 + + # Publish this release into the xlings ecosystem, after ALL platform builds + # have uploaded their assets: + # ① mirror binaries → xlings-res/mcpp (GitHub + GitCode) so XLINGS_RES + # downloads resolve on every platform (incl. the CN/GitCode path); + # ② open a PR against openxlings/xim-pkgindex bumping mcpp to this version + # (a maintainer merges it — index git source is not on the critical path). + # Best-effort / non-blocking: a failure here never fails the release. + # Shared vendored scripts live in .github/tools/ (kept in sync with xlings). + publish-ecosystem: + needs: [build-release, build-linux-aarch64, build-macos, build-windows] + runs-on: ubuntu-latest + env: + XLINGS_RES_TOKEN: ${{ secrets.XLINGS_RES_TOKEN }} + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + XIM_PKGINDEX_TOKEN: ${{ secrets.XIM_PKGINDEX_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Determine version + id: version + run: echo "version=$(awk -F '\"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml)" >> "$GITHUB_OUTPUT" + + - name: Mirror binaries to xlings-res/mcpp (gh + gtc) + if: ${{ env.XLINGS_RES_TOKEN != '' }} + env: + GH_TOKEN: ${{ secrets.XLINGS_RES_TOKEN }} + run: | + chmod +x .github/tools/gtc .github/tools/mirror_res.sh + export PATH="$PWD/.github/tools:$PATH" + bash .github/tools/mirror_res.sh mcpp "${{ steps.version.outputs.version }}" \ + || echo "binary mirror reported issues (non-blocking)" + + - name: Open index bump PR (xim-pkgindex) + if: ${{ env.XIM_PKGINDEX_TOKEN != '' }} + env: + PKGINDEX_TOKEN: ${{ secrets.XIM_PKGINDEX_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + chmod +x .github/tools/bump_index.sh + bash .github/tools/bump_index.sh mcpp "${{ steps.version.outputs.version }}" \ + || echo "index bump reported issues (non-blocking)"