Files
github-action/.gitea/scoped_workflows/sync-github-metadata.yml
T
TirsvadandClaude Sonnet 5.5 4de5438a88 Add project plan, milestones and SQA framework; document actual capabilities
Adopt the SQA/QC framework as a submodule and plan the project before any
further code: Business Case, Stakeholder Analysis (S01-S05), Project Plan with
use case candidates and prioritisation, and milestones MIL-001..MIL-004
(24 tasks, synced to Gitea as milestones 18-21 and issues #1-#24).

README now separates implemented from planned capabilities, has an English
sequence diagram and points at the scoped workflow. The workflow file moves
out of the repository root and out of .gitea/workflows/; the scoped copy is
unchanged in behaviour here and still contains the known `exit 0` syntax
error, which MIL-001 fixes.

Refs #1
Refs #2
Refs #3
Refs #6

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 20:28:44 +08:00

163 lines
6.2 KiB
YAML

name: Sync GitHub mirror metadata
on:
push:
branches: [ main ]
workflow_dispatch:
schedule:
- cron: "17 3 * * *"
jobs:
sync-metadata:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Sync description and topics
env:
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_CREDENTIALS: ${{ secrets.TOKEN_FOR_GITEA }}
SOURCE_REPOSITORY: ${{ gitea.repository }}
GITHUB_CREDENTIALS: ${{ secrets.CREDENTIALS_FOR_GITHUB }}
run: |
python3 - <<'PY'
import json
import os
import urllib.error
import urllib.parse
import urllib.request
def request_json(url, method="GET", headers=None, body=None):
request = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8") if body is not None else None,
headers=headers or {},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
content = response.read()
return json.loads(content) if content else None
except urllib.error.HTTPError as error:
raise RuntimeError(
f"API request failed with HTTP {error.code} ({error.reason})"
) from None
raw_credentials = os.environ.get("GITHUB_CREDENTIALS", "").strip()
if not raw_credentials:
raise RuntimeError("CREDENTIALS_FOR_GITHUB is missing or empty.")
try:
credentials = json.loads(raw_credentials)
except json.JSONDecodeError:
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must contain valid JSON."
) from None
if not isinstance(credentials, dict):
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must be a JSON object."
)
github_token = credentials.get("GITHUB_PAT")
if not isinstance(github_token, str) or not github_token.strip():
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT."
)
raw_gitea_credentials = os.environ.get("GITEA_CREDENTIALS", "").strip()
if not raw_gitea_credentials:
raise RuntimeError("TOKEN_FOR_GITEA is missing or empty.")
try:
gitea_credentials = json.loads(raw_gitea_credentials)
except json.JSONDecodeError:
gitea_token = raw_gitea_credentials
else:
if isinstance(gitea_credentials, dict):
gitea_token = gitea_credentials.get("GITEA_TOKEN")
elif isinstance(gitea_credentials, str):
gitea_token = gitea_credentials
else:
gitea_token = None
if not isinstance(gitea_token, str) or not gitea_token.strip():
raise RuntimeError(
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN."
)
source_owner, separator, source_repo = os.environ[
"SOURCE_REPOSITORY"
].partition("/")
if not separator or not source_owner or not source_repo:
raise RuntimeError("Could not determine the Gitea source repository.")
gitea_api_url = os.environ["GITEA_API_URL"].rstrip("/")
source_url = f"{gitea_api_url}/repos/{source_owner}/{source_repo}"
gitea_headers = {
"Authorization": f"token {gitea_token.strip()}",
"Accept": "application/json",
}
source = request_json(source_url, headers=gitea_headers)
mirrors = request_json(
f"{source_url}/push_mirrors",
headers=gitea_headers,
)
if not isinstance(mirrors, list):
print("Gitea returned an invalid push mirror list.")
exit 0
github_targets = []
for mirror in mirrors:
remote_address = mirror.get("remote_address", "")
if remote_address.startswith("git@github.com:"):
mirror_path = remote_address.split(":", 1)[1]
else:
parsed_remote = urllib.parse.urlsplit(remote_address)
if parsed_remote.hostname != "github.com":
continue
mirror_path = parsed_remote.path.lstrip("/")
mirror_path = mirror_path.removesuffix(".git").strip("/")
path_parts = mirror_path.split("/")
if len(path_parts) != 2 or not all(path_parts):
raise RuntimeError(
"Could not determine the GitHub owner and repository "
"from a configured push mirror."
)
github_targets.append(tuple(path_parts))
if len(github_targets) != 1:
raise RuntimeError(
"Expected exactly one GitHub push mirror for this repository; "
f"found {len(github_targets)}."
)
github_owner, github_repo = github_targets[0]
github_api_url = (
"https://api.github.com/repos/"
f"{urllib.parse.quote(github_owner, safe='')}/"
f"{urllib.parse.quote(github_repo, safe='')}"
)
github_headers = {
"Authorization": f"Bearer {github_token.strip()}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
}
request_json(
github_api_url,
method="PATCH",
headers=github_headers,
body={"description": source.get("description") or ""},
)
request_json(
f"{github_api_url}/topics",
method="PUT",
headers=github_headers,
body={"names": source.get("topics") or []},
)
print(f"Synced description and topics to {github_owner}/{github_repo}.")
PY