diff --git a/.github/workflows/code-mirror.yml b/.github/workflows/code-mirror.yml new file mode 100644 index 0000000..5567e08 --- /dev/null +++ b/.github/workflows/code-mirror.yml @@ -0,0 +1,37 @@ +name: Mirror source code to GitVerse + +on: + push: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: code-mirror-${{ github.ref }} + cancel-in-progress: true + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Mirror all branches + env: + GITVERSE_PAT: ${{ secrets.GITVERSE_PAT }} + GITVERSE_ORG: ${{ vars.GITVERSE_ORG || 'dcr_labs' }} + GITVERSE_REPO: ${{ vars.GITVERSE_REPO || 'dcr' }} + run: | + set -euo pipefail + test -n "$GITVERSE_PAT" || { echo 'GITVERSE_PAT is not configured' >&2; exit 1; } + remote="https://oauth2:${GITVERSE_PAT}@gitverse.ru/${GITVERSE_ORG}/${GITVERSE_REPO}.git" + git remote add gitverse "$remote" + # Populate local refs first, so a manual run mirrors every branch too. + git fetch origin '+refs/heads/*:refs/heads/*' + git push --prune --force gitverse 'refs/heads/*:refs/heads/*' diff --git a/.github/workflows/release-mirror.yml b/.github/workflows/release-mirror.yml new file mode 100644 index 0000000..80c1280 --- /dev/null +++ b/.github/workflows/release-mirror.yml @@ -0,0 +1,186 @@ +name: Mirror releases to GitVerse, SourceCraft, and GitLab + +on: + workflow_run: + workflows: + - Release + types: + - completed + workflow_dispatch: + inputs: + tag: + description: Git tag of the GitHub release to mirror (for example v0.7.1) + required: true + type: string + +permissions: + contents: read + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - name: Resolve release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + MANUAL_TAG: ${{ inputs.tag }} + WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + tag="$MANUAL_TAG" + if [ -z "$tag" ] && [[ "$WORKFLOW_HEAD_BRANCH" == v* ]]; then + tag="$WORKFLOW_HEAD_BRANCH" + fi + if [ -z "$tag" ] && [ -n "$WORKFLOW_HEAD_SHA" ]; then + releases=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases?per_page=100") + while IFS= read -r candidate; do + ref=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/tags/${candidate}") + sha=$(jq -r '.object.sha' <<< "$ref") + if [ "$(jq -r '.object.type' <<< "$ref")" = tag ]; then + sha=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/tags/${sha}" | jq -r '.object.sha') + fi + if [ "$sha" = "$WORKFLOW_HEAD_SHA" ]; then + tag="$candidate" + break + fi + done < <(jq -r '.[].tag_name' <<< "$releases") + fi + if [ -z "$tag" ] || [[ "$tag" != v* ]]; then + echo 'No release tag was found; release.yml may have failed before creating a GitHub release.' + echo 'found=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo 'found=true' >> "$GITHUB_OUTPUT" + + - name: Download GitHub release metadata and assets + if: steps.release.outputs.found == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + mkdir -p assets + curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > release.json + jq -e '.tag_name == $tag' --arg tag "$TAG" release.json >/dev/null + jq -r '.assets[] | [.name, .browser_download_url] | @tsv' release.json | + while IFS=$'\t' read -r name url; do + curl -fsSL -L -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Accept: application/octet-stream' -o "assets/$name" "$url" + done + + - name: Sync tag and release to GitVerse + if: steps.release.outputs.found == 'true' + env: + GITVERSE_PAT: ${{ secrets.GITVERSE_PAT }} + GITVERSE_ORG: ${{ vars.GITVERSE_ORG || 'dcr_labs' }} + GITVERSE_REPO: ${{ vars.GITVERSE_REPO || 'dcr' }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + test -n "$GITVERSE_PAT" || { echo 'GITVERSE_PAT is not configured' >&2; exit 1; } + project="${GITVERSE_ORG}%2F${GITVERSE_REPO}" + remote="https://oauth2:${GITVERSE_PAT}@gitverse.ru/${GITVERSE_ORG}/${GITVERSE_REPO}.git" + git clone --mirror "https://github.com/${GITHUB_REPOSITORY}.git" mirror.git + git -C mirror.git remote add gitverse "$remote" + git -C mirror.git push gitverse "refs/tags/${TAG}:refs/tags/${TAG}" + + title=$(jq -r '.name // .tag_name' release.json) + notes=$(jq -r '.body // ""' release.json) + payload=$(jq -n --arg tag "$TAG" --arg title "$title" --arg notes "$notes" \ + '{name:$title, tag_name:$tag, description:$notes, ref:$tag}') + curl -fsSL -X POST -H "PRIVATE-TOKEN: $GITVERSE_PAT" \ + -H 'Content-Type: application/json' --data "$payload" \ + "https://gitverse.ru/api/v4/projects/${project}/releases" || \ + curl -fsSL -X PUT -H "PRIVATE-TOKEN: $GITVERSE_PAT" \ + -H 'Content-Type: application/json' --data "$payload" \ + "https://gitverse.ru/api/v4/projects/${project}/releases/${TAG}" + for file in assets/*; do + [ -f "$file" ] || continue + upload=$(curl -fsSL -X POST -H "PRIVATE-TOKEN: $GITVERSE_PAT" \ + -F "file=@$file" "https://gitverse.ru/api/v4/projects/${project}/uploads") + url=$(jq -r '.url // .file_path' <<< "$upload") + name=$(basename "$file") + link=$(jq -n --arg name "$name" --arg url "$url" '{name:$name,url:$url}') + curl -fsSL -X POST -H "PRIVATE-TOKEN: $GITVERSE_PAT" \ + -H 'Content-Type: application/json' --data "$link" \ + "https://gitverse.ru/api/v4/projects/${project}/releases/${TAG}/assets/links" || true + done + + - name: Sync tag and release to SourceCraft + if: steps.release.outputs.found == 'true' + env: + SOURCECRAFT_PAT: ${{ secrets.SOURCECRAFT_PAT }} + SOURCECRAFT_ORG: ${{ vars.SOURCECRAFT_ORG || 'dexoron-dev' }} + SOURCECRAFT_REPO: ${{ vars.SOURCECRAFT_REPO || 'dcr' }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + test -n "$SOURCECRAFT_PAT" || { echo 'SOURCECRAFT_PAT is not configured' >&2; exit 1; } + remote="https://anyname:${SOURCECRAFT_PAT}@git.sourcecraft.dev/${SOURCECRAFT_ORG}/${SOURCECRAFT_REPO}.git" + git clone "$remote" sourcecraft + git -C sourcecraft remote add github "https://github.com/${GITHUB_REPOSITORY}.git" + git -C sourcecraft fetch github "refs/tags/${TAG}:refs/tags/${TAG}" + git -C sourcecraft push origin "refs/tags/${TAG}" + + title=$(jq -r '.name // .tag_name' release.json) + notes=$(jq -r '.body // ""' release.json) + payload=$(jq -n --arg tag "$TAG" --arg title "$title" --arg notes "$notes" \ + '{tag:$tag,title:$title,release_notes:$notes,publish:true}') + curl -fsSL -X POST -H "Authorization: Bearer $SOURCECRAFT_PAT" \ + -H 'Content-Type: application/json' --data "$payload" \ + "https://api.sourcecraft.tech/repos/${SOURCECRAFT_ORG}/${SOURCECRAFT_REPO}/releases" || \ + echo 'SourceCraft release already exists; continuing with assets.' + for file in assets/*; do + [ -f "$file" ] || continue + curl -fsSL -X POST -H "Authorization: Bearer $SOURCECRAFT_PAT" \ + -F "name=$(basename "$file")" -F "file=@$file" \ + "https://api.sourcecraft.tech/repos/${SOURCECRAFT_ORG}/${SOURCECRAFT_REPO}/releases/tag/${TAG}/attachments" || true + done + + - name: Sync release to GitLab + if: steps.release.outputs.found == 'true' + env: + GITLAB_PAT: ${{ secrets.GITLAB_PAT }} + GITLAB_PROJECT: ${{ vars.GITLAB_PROJECT || 'dexoron-dev/dcr-tool/dcr' }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + test -n "$GITLAB_PAT" || { echo 'GITLAB_PAT is not configured' >&2; exit 1; } + project_id="${GITLAB_PROJECT//\//%2F}" + api="https://gitlab.com/api/v4/projects/${project_id}" + title=$(jq -r '.name // .tag_name' release.json) + notes=$(jq -r '.body // ""' release.json) + payload=$(jq -n --arg tag "$TAG" --arg title "$title" --arg notes "$notes" \ + '{tag_name:$tag, name:$title, description:$notes}') + + # Tags are already mirrored by GitLab's built-in repository mirror. + curl -fsSL -X POST -H "PRIVATE-TOKEN: $GITLAB_PAT" \ + -H 'Content-Type: application/json' --data "$payload" \ + "${api}/releases" || \ + curl -fsSL -X PUT -H "PRIVATE-TOKEN: $GITLAB_PAT" \ + -H 'Content-Type: application/json' --data "$payload" \ + "${api}/releases/${TAG}" + + for file in assets/*; do + [ -f "$file" ] || continue + upload=$(curl -fsSL -X POST -H "PRIVATE-TOKEN: $GITLAB_PAT" \ + -F "file=@$file" "${api}/uploads") + url="https://gitlab.com/${GITLAB_PROJECT}$(jq -r '.url' <<< "$upload")" + name=$(basename "$file") + link=$(jq -n --arg name "$name" --arg url "$url" \ + '{name:$name, url:$url, link_type:"other"}') + curl -fsSL -X POST -H "PRIVATE-TOKEN: $GITLAB_PAT" \ + -H 'Content-Type: application/json' --data "$link" \ + "${api}/releases/${TAG}/assets/links" || true + done diff --git a/.gitignore b/.gitignore index d0a4f5f..8c9d461 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ sandbox/** personal/ .* -!.gitignore \ No newline at end of file +!.gitignore +!.github/ \ No newline at end of file diff --git a/README.md b/README.md index a73181c..785e694 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,30 @@ Full docs at **[dcr.dexoron.su](https://dcr.dexoron.su)** or in the [`docs/`](do --- +## Repository Mirrors + +The primary repository is hosted on GitHub. The following mirrors are available for source code and releases: + +| Region | Mirror | Contents | +|---|---|---| +| Russia | [GitVerse](https://gitverse.ru/dcr_labs/dcr) **(recommended)** | Source code and releases | +| Russia | [SourceCraft](https://sourcecraft.dev/dexoron-dev/dcr) | Source code and releases | +| Worldwide | [SourceForge](https://sourceforge.net/projects/dcr-tool/) **(recommended)** | Source code and releases | +| Worldwide | [GitLab](https://gitlab.com/dexoron-dev/dcr-tool/dcr) | Source code and releases | + +--- + +## VS Code Extension + +Install the recommended [DCR extension by Dexoron-Labs](https://marketplace.visualstudio.com/items?itemName=Dexoron-Labs.dcr) for VS Code. Do not install Microsoft C/C++ extensions for DCR projects; DCR uses `clangd` instead. If you need debugging with Microsoft's debugger, use MS C/C++ only for its debugger component. + +Recommended companion extensions: + +- [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) +- [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb), or MS C/C++ for debugger support only + +--- + ## Contributors
diff --git a/docs/ide-integration.mdx b/docs/ide-integration.mdx index ba08f23..7004298 100644 --- a/docs/ide-integration.mdx +++ b/docs/ide-integration.mdx @@ -6,6 +6,13 @@ sidebar_label: IDE Integration ## VS Code +Install the recommended [DCR extension by Dexoron-Labs](https://marketplace.visualstudio.com/items?itemName=Dexoron-Labs.dcr). Do not install Microsoft C/C++ extensions for DCR projects: DCR uses `clangd`. MS C/C++ is acceptable only when you need its debugger. + +Recommended companion extensions: + +- [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) +- [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb), or MS C/C++ for debugger support only + ```bash dcr gen vscode ``` diff --git a/scripts/sync-releases-to-gitlab.py b/scripts/sync-releases-to-gitlab.py deleted file mode 100644 index d1e5bed..0000000 --- a/scripts/sync-releases-to-gitlab.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 -"""Sync all GitHub Releases (with assets) to GitLab Releases.""" - -import json -import os -import sys -import time -import urllib.error -import urllib.request -import uuid - -GL_TOKEN = os.environ.get("GL_TOKEN") -if not GL_TOKEN: - print("FATAL: GL_TOKEN environment variable not set", file=sys.stderr) - sys.exit(1) - -GL_PROJECT = "dexoron-studio%2Fdcr" -GL_PROJECT_RAW = "dexoron-studio/dcr" -GL_API = "https://gitlab.com/api/v4" -GH_REPO = "dexoron/dcr" - - -def gl_api(method, path, data=None, filepath=None, retries=5): - url = f"{GL_API}/{path}" - headers = {"PRIVATE-TOKEN": GL_TOKEN} - - if data is not None and filepath is None: - headers["Content-Type"] = "application/json" - body = json.dumps(data).encode() - elif filepath: - boundary = uuid.uuid4().hex - with open(filepath, "rb") as f: - fdata = f.read() - fname = os.path.basename(filepath) - parts = [] - parts.append(f"--{boundary}".encode()) - parts.append(f'Content-Disposition: form-data; name="file"; filename="{fname}"'.encode()) - parts.append(b"Content-Type: application/octet-stream") - parts.append(b"") - parts.append(fdata) - parts.append(f"--{boundary}--".encode()) - parts.append(b"") - body = b"\r\n".join(parts) - headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" - else: - body = None - - for attempt in range(retries): - try: - req = urllib.request.Request(url, data=body, headers=headers, method=method) - with urllib.request.urlopen(req) as resp: - text = resp.read().decode() - return json.loads(text) if text else None - except urllib.error.HTTPError as e: - body_text = e.read().decode() if e.fp else "" - if e.code in (409, 429) and attempt < retries - 1: - time.sleep(3) - continue - if attempt < retries - 1: - time.sleep(1) - continue - print(f" HTTP {e.code}: {body_text[:300]}", file=sys.stderr) - return None - except Exception as e: - if attempt < retries - 1: - time.sleep(1) - continue - print(f" Error: {e}", file=sys.stderr) - return None - return None - - -def download_file(url, dest): - with urllib.request.urlopen(url) as resp: - with open(dest, "wb") as f: - f.write(resp.read()) - - -# --- MAIN --- -print("Fetching GitHub releases...") -with urllib.request.urlopen(f"https://api.github.com/repos/{GH_REPO}/releases?per_page=50") as r: - gh_releases = json.loads(r.read().decode()) - -print(f"Found {len(gh_releases)} releases on GitHub") - -# Reverse to process oldest first (GitHub returns newest first) -gh_releases.reverse() - -print("Fetching existing GitLab releases and their assets...") -existing = gl_api("GET", f"projects/{GL_PROJECT}/releases?per_page=100") or [] -existing = existing if isinstance(existing, list) else [] - -# Build map: tag -> set of existing asset names -gl_assets = {} -for rel in existing: - tag = rel["tag_name"] - links = rel.get("assets", {}).get("links", []) or rel.get("assets", {}).get("sources", []) or [] - gl_assets[tag] = {l.get("name", "") for l in links if isinstance(l, dict)} - # Also get the release description to check if it needs updating - gl_assets[tag + "__desc"] = rel.get("description", "") - -print(f"Existing GitLab releases: {len(existing)}") - -download_dir = "/tmp/gh-assets" -os.makedirs(download_dir, exist_ok=True) - -# Reverse: process older first so newer releases with many assets get their description too -# Actually, process in original order -total = len(gh_releases) -for i, rel in enumerate(gh_releases): - tag = rel["tag_name"] - body = rel.get("body", "") or "" - assets = rel.get("assets", []) - if isinstance(assets, dict): - assets = assets.get("nodes", []) or [] - - print(f"\n{'='*60}") - print(f"[{i+1}/{total}] {tag} ({len(assets)} assets)") - - # Create or update release - existing_tag = tag in gl_assets - if not existing_tag: - print(f" Creating release...") - result = gl_api("POST", f"projects/{GL_PROJECT}/releases", data={ - "tag_name": tag, - "name": rel.get("name", tag), - "description": body, - "ref": "master", - }) - if result is None: - print(f" FAILED — skip") - continue - print(f" OK") - else: - # Update description if needed - existing_desc = gl_assets.get(tag + "__desc", "") - if body and body != existing_desc: - print(f" Updating description...") - gl_api("PUT", f"projects/{GL_PROJECT}/releases/{urllib.request.quote(tag, safe='')}", - data={"description": body}) - - existing_links = gl_assets.get(tag, set()) - - for j, asset in enumerate(assets): - aname = asset.get("name", "") - aurl = asset.get("browser_download_url", "") - if not aurl: - continue - - if aname in existing_links: - print(f" [{j+1}/{len(assets)}] {aname} — already exists") - continue - - fpath = os.path.join(download_dir, aname) - print(f" [{j+1}/{len(assets)}] {aname}...", end=" ", flush=True) - - # Download from GitHub - try: - download_file(aurl, fpath) - except Exception as e: - print(f"download failed: {e}") - continue - - # Upload to GitLab - up = gl_api("POST", f"projects/{GL_PROJECT}/uploads", filepath=fpath) - if not up or "url" not in up: - size_mb = os.path.getsize(fpath) / (1024 * 1024) - print(f"upload failed ({size_mb:.1f} MB)") - os.remove(fpath) - continue - - file_url = up["url"] - full_url = f"https://gitlab.com/{GL_PROJECT_RAW}{file_url}" - - # Link asset to release - encoded_tag = urllib.request.quote(tag, safe='') - link = gl_api("POST", - f"projects/{GL_PROJECT}/releases/{encoded_tag}/assets/links", - data={"name": aname, "url": full_url}, - retries=5) - if link: - print(f"linked") - existing_links.add(aname) - else: - print(f"link failed") - - os.remove(fpath) - - time.sleep(0.3) - -print(f"\n{'='*60}") -print("Done!") \ No newline at end of file