Skip to content

Commit 5bfb2a1

Browse files
feat(release): publish into xlings ecosystem (mirror xlings-res/mcpp + index bump PR) (#194)
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). Co-authored-by: sunrisepeak <x.d2learn.org@gmail.com>
1 parent 291da7a commit 5bfb2a1

4 files changed

Lines changed: 457 additions & 0 deletions

File tree

.github/tools/bump_index.sh

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env bash
2+
# Open a PR against openxlings/xim-pkgindex that bumps <project> to <version>.
3+
#
4+
# The actual lua edit is delegated to the index repo's OWN
5+
# `.github/scripts/version-check.py --apply --only <project>`, so this script
6+
# stays agnostic to each package's contract:
7+
# - res_versioned packages (e.g. xlings): appends `["<ver>"] = "XLINGS_RES"`.
8+
# - url_template packages (e.g. mcpp): downloads the artifact, computes
9+
# sha256, appends `["<ver>"] = { url, sha256 }`.
10+
# and always bumps `["latest"].ref`. Existing version entries are untouched.
11+
#
12+
# This opens a PR only — a maintainer reviews and merges it (index git source
13+
# is intentionally NOT on the release critical path; availability is already
14+
# guaranteed by the xlings-res binary mirror + index artifact). On merge, the
15+
# index repo republishes its artifact + gitee-sync picks it up.
16+
#
17+
# Usage: tools/bump_index.sh <project> <version> # e.g. mcpp 0.0.82
18+
# Auth:
19+
# PKGINDEX_TOKEN (required) — write to openxlings/xim-pkgindex (clone/push/PR)
20+
# GITHUB_TOKEN (optional) — GitHub API rate headroom + artifact download
21+
# for url_template packages (version-check.py)
22+
set -euo pipefail
23+
24+
PROJ="${1:?usage: bump_index.sh <project> <version>}"
25+
VER="${2:?usage: bump_index.sh <project> <version>}"
26+
INDEX_REPO="${INDEX_REPO:-openxlings/xim-pkgindex}"
27+
BRANCH="bump/${PROJ}-${VER}"
28+
TOKEN="${PKGINDEX_TOKEN:?PKGINDEX_TOKEN required (write to $INDEX_REPO)}"
29+
30+
info() { echo "[bump] $*"; }
31+
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
32+
33+
info "cloning $INDEX_REPO"
34+
git clone -q --depth 20 "https://x-access-token:${TOKEN}@github.com/${INDEX_REPO}.git" "$WORK/idx"
35+
cd "$WORK/idx"
36+
git config user.email "ci@xlings.dev"
37+
git config user.name "xlings-ci"
38+
git checkout -q -B "$BRANCH"
39+
40+
info "version-check.py --apply --only $PROJ"
41+
python3 .github/scripts/version-check.py --apply --only "$PROJ" > "$WORK/report.json" || {
42+
echo "[bump] version-check failed:" >&2; cat "$WORK/report.json" >&2 2>/dev/null || true; exit 1;
43+
}
44+
45+
if git diff --quiet; then
46+
info "no change — index already tracks the latest $PROJ; nothing to PR"
47+
exit 0
48+
fi
49+
50+
# Sanity: the resulting latest.ref should equal the released version. A
51+
# mismatch usually means GitHub's releases/latest hasn't caught up yet (API
52+
# lag) or a newer release exists — warn but still open the PR.
53+
GOT="$(grep -oE 'ref = "[^"]+"' pkgs/*/"${PROJ}".lua 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)"
54+
if [[ -n "$GOT" && "$GOT" != "$VER" ]]; then
55+
echo "[bump] WARN: bumped latest.ref to '$GOT' but release version is '$VER' (upstream 'latest' differs?)"
56+
fi
57+
58+
git add -A
59+
git commit -qm "bump(${PROJ}): track ${VER} as latest
60+
61+
Auto-generated by the ${PROJ} release pipeline (bump_index.sh).
62+
Applied via version-check.py --apply --only ${PROJ}."
63+
64+
info "pushing $BRANCH (force — bot branch)"
65+
git push -q -f "https://x-access-token:${TOKEN}@github.com/${INDEX_REPO}.git" "$BRANCH"
66+
67+
# Open PR — idempotent: a force-push updates an already-open PR in place.
68+
export GH_TOKEN="$TOKEN"
69+
if gh pr view "$BRANCH" -R "$INDEX_REPO" >/dev/null 2>&1; then
70+
info "PR already open for $BRANCH (updated by force-push)"
71+
else
72+
gh pr create -R "$INDEX_REPO" --base main --head "$BRANCH" \
73+
--title "bump(${PROJ}): track ${VER} as latest" \
74+
--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}\`).
75+
76+
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)."
77+
info "PR opened"
78+
fi

.github/tools/gtc

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env python3
2+
"""gtc — gitcode tool
3+
4+
Subcommands:
5+
repo create <owner>/<name> [--description STR] [--private]
6+
repo push <owner>/<name> <local_dir> [--branch BRANCH]
7+
release create <owner>/<repo> --tag TAG [--name NAME] [--body-file FILE] [--target main] [--prerelease]
8+
release upload <owner>/<repo> --tag TAG <file...>
9+
release publish <owner>/<repo> --tag TAG [--name NAME] [--body-file FILE] [--target main] [--prerelease] [--asset FILE]...
10+
pr create <owner>/<repo> --title T --head BRANCH --base BRANCH [--body-file FILE]
11+
12+
Token / config:
13+
~/.config/gitcode-tool/config.json → {"token": "...", "api_base": "https://api.gitcode.com/api/v5"}
14+
override: --token TOKEN or GITCODE_TOKEN env var
15+
"""
16+
import argparse
17+
import json
18+
import os
19+
import subprocess
20+
import sys
21+
import urllib.parse
22+
import urllib.request
23+
import urllib.error
24+
from pathlib import Path
25+
26+
CONFIG_PATH = Path.home() / ".config" / "gitcode-tool" / "config.json"
27+
28+
29+
def load_config():
30+
cfg = {"api_base": "https://api.gitcode.com/api/v5"}
31+
if CONFIG_PATH.is_file():
32+
cfg.update(json.loads(CONFIG_PATH.read_text()))
33+
cfg["token"] = os.environ.get("GITCODE_TOKEN") or cfg.get("token") or ""
34+
return cfg
35+
36+
37+
def http(method, url, *, token, headers=None, json_body=None, data=None, expect=(200, 201)):
38+
h = {"PRIVATE-TOKEN": token, "Accept": "application/json"}
39+
if json_body is not None:
40+
h["Content-Type"] = "application/json"
41+
body = json.dumps(json_body).encode()
42+
elif data is not None:
43+
body = data
44+
else:
45+
body = None
46+
if headers:
47+
h.update(headers)
48+
req = urllib.request.Request(url, data=body, headers=h, method=method)
49+
try:
50+
with urllib.request.urlopen(req, timeout=120) as r:
51+
raw = r.read()
52+
return r.status, raw
53+
except urllib.error.HTTPError as e:
54+
raw = e.read()
55+
if e.code in expect:
56+
return e.code, raw
57+
# gitcode often returns HTTP 400 with a different error_code in the body
58+
try:
59+
j = json.loads(raw)
60+
body_code = j.get("error_code")
61+
msg = (j.get("error_message") or "").lower()
62+
if 422 in expect and ("already exists" in msg or body_code == 422):
63+
return 422, raw
64+
# gitcode may wrap 404 inside HTTP 400
65+
if 404 in expect and (body_code == 404 or "not found" in msg):
66+
return 404, raw
67+
except Exception:
68+
pass
69+
sys.stderr.write(f"HTTP {e.code} {url}\n{raw.decode(errors='replace')[:500]}\n")
70+
sys.exit(2)
71+
72+
73+
def api_json(cfg, method, path, **kwargs):
74+
url = cfg["api_base"] + path
75+
status, raw = http(method, url, token=cfg["token"], **kwargs)
76+
if not raw:
77+
return status, None
78+
try:
79+
return status, json.loads(raw)
80+
except json.JSONDecodeError:
81+
return status, raw.decode(errors="replace")
82+
83+
84+
# ─────────────── repo ───────────────
85+
86+
def cmd_repo_create(args, cfg):
87+
owner, name = args.repo.split("/", 1)
88+
payload = {"name": name, "path": name, "description": args.description or "", "private": args.private}
89+
# personal user repos: POST /user/repos ; org repos: POST /orgs/:org/repos
90+
me_status, me = api_json(cfg, "GET", "/user")
91+
if me_status == 200 and isinstance(me, dict) and me.get("login") == owner:
92+
path = "/user/repos"
93+
else:
94+
path = f"/orgs/{owner}/repos"
95+
status, body = api_json(cfg, "POST", path, json_body=payload, expect=(200, 201, 422))
96+
if status == 422:
97+
print(f"warn: repo {owner}/{name} may already exist")
98+
return
99+
print(f"created {body.get('full_name', owner+'/'+name)}{body.get('html_url') or body.get('url')}")
100+
101+
102+
def cmd_repo_push(args, cfg):
103+
owner, name = args.repo.split("/", 1)
104+
local = Path(args.local_dir).resolve()
105+
if not local.is_dir():
106+
sys.exit(f"local_dir not found: {local}")
107+
branch = args.branch or "main"
108+
user = api_json(cfg, "GET", "/user")[1]
109+
login = user.get("login") if isinstance(user, dict) else owner
110+
remote_url = f"https://{login}:{cfg['token']}@gitcode.com/{owner}/{name}.git"
111+
112+
def run(*cmd):
113+
subprocess.run(cmd, cwd=local, check=True)
114+
115+
if not (local / ".git").is_dir():
116+
run("git", "init", "-b", branch)
117+
# ensure remote
118+
r = subprocess.run(["git", "-C", str(local), "remote"], capture_output=True, text=True)
119+
remotes = r.stdout.split()
120+
if "gitcode" in remotes:
121+
run("git", "remote", "set-url", "gitcode", remote_url)
122+
else:
123+
run("git", "remote", "add", "gitcode", remote_url)
124+
# commit any pending
125+
subprocess.run(["git", "-C", str(local), "add", "-A"], check=True)
126+
diff = subprocess.run(["git", "-C", str(local), "diff", "--cached", "--quiet"]).returncode
127+
if diff != 0:
128+
run("git", "commit", "-m", args.message or "init")
129+
run("git", "push", "-u", "gitcode", branch)
130+
print(f"pushed → https://gitcode.com/{owner}/{name}")
131+
132+
133+
# ─────────────── release ───────────────
134+
135+
def _release_payload(args):
136+
if args.body_file:
137+
body = Path(args.body_file).read_text().strip()
138+
else:
139+
body = ""
140+
if not body:
141+
body = args.name or args.tag
142+
return {
143+
"tag_name": args.tag,
144+
"name": args.name or args.tag,
145+
"body": body,
146+
"target_commitish": args.target or "main",
147+
"prerelease": bool(args.prerelease),
148+
}
149+
150+
151+
def cmd_release_create(args, cfg):
152+
payload = _release_payload(args)
153+
# check existing first to make this idempotent
154+
s, _ = api_json(cfg, "GET", f"/repos/{args.repo}/releases/tags/{args.tag}", expect=(200, 404))
155+
if s == 200:
156+
print(f"release {args.repo}@{args.tag} already exists, skipping")
157+
return
158+
status, body = api_json(cfg, "POST", f"/repos/{args.repo}/releases", json_body=payload,
159+
expect=(200, 201, 422))
160+
if status == 422:
161+
print(f"warn: release {args.tag} may already exist on {args.repo}")
162+
return
163+
print(f"release created: {args.repo}@{args.tag}")
164+
165+
166+
def _upload_one(cfg, repo, tag, file_path):
167+
fname = Path(file_path).name
168+
url = f"{cfg['api_base']}/repos/{repo}/releases/{tag}/upload_url?file_name={urllib.parse.quote(fname)}"
169+
status, info = http("GET", url, token=cfg["token"])
170+
info = json.loads(info)
171+
put_url = info["url"]
172+
h = info["headers"]
173+
with open(file_path, "rb") as f:
174+
data = f.read()
175+
req = urllib.request.Request(put_url, data=data, headers=h, method="PUT")
176+
with urllib.request.urlopen(req, timeout=600) as r:
177+
ok = r.status == 200
178+
body = r.read().decode(errors="replace")[:200]
179+
if not ok:
180+
sys.exit(f"upload {fname} failed: {body}")
181+
print(f" uploaded: {fname} ({len(data)} bytes)")
182+
183+
184+
def cmd_release_upload(args, cfg):
185+
for f in args.files:
186+
_upload_one(cfg, args.repo, args.tag, f)
187+
188+
189+
def cmd_release_publish(args, cfg):
190+
cmd_release_create(args, cfg)
191+
if args.asset:
192+
for f in args.asset:
193+
_upload_one(cfg, args.repo, args.tag, f)
194+
195+
196+
# ─────────────── pull request ───────────────
197+
198+
def cmd_pr_create(args, cfg):
199+
body = ""
200+
if args.body_file:
201+
body = Path(args.body_file).read_text()
202+
payload = {"title": args.title, "head": args.head, "base": args.base, "body": body}
203+
status, resp = api_json(cfg, "POST", f"/repos/{args.repo}/pulls", json_body=payload)
204+
print(f"PR created: {resp.get('html_url') or resp}")
205+
206+
207+
# ─────────────── argparse ───────────────
208+
209+
def build_parser():
210+
p = argparse.ArgumentParser(prog="gtc")
211+
p.add_argument("--token", help="override token")
212+
sub = p.add_subparsers(dest="cmd", required=True)
213+
214+
rp = sub.add_parser("repo").add_subparsers(dest="op", required=True)
215+
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)
216+
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)
217+
218+
re = sub.add_parser("release").add_subparsers(dest="op", required=True)
219+
for name, fn in (("create", cmd_release_create), ("publish", cmd_release_publish)):
220+
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")
221+
if name == "publish":
222+
x.add_argument("--asset", action="append", default=[])
223+
x.set_defaults(_fn=fn)
224+
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)
225+
226+
pr = sub.add_parser("pr").add_subparsers(dest="op", required=True)
227+
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)
228+
229+
return p
230+
231+
232+
def main():
233+
args = build_parser().parse_args()
234+
cfg = load_config()
235+
if args.token:
236+
cfg["token"] = args.token
237+
if not cfg["token"]:
238+
sys.exit("no token: set GITCODE_TOKEN env or write to ~/.config/gitcode-tool/config.json")
239+
args._fn(args, cfg)
240+
241+
242+
if __name__ == "__main__":
243+
main()

0 commit comments

Comments
 (0)