Add credential preflight, categorized errors and operations guides

Sync workflow: every failure is now reported as `ERROR: [category] message`
with the categories configuration, credentials (HTTP 401), permissions
(HTTP 403) and api (other HTTP errors, unreachable service); HTTP 404 is a
configuration error. Before changing anything the workflow checks that the
GitHub token can administer the mirror repository and stops with a
permissions error otherwise. No secret value is printed.

Guides: onboarding (with runner labels, prerequisites and offline runner
symptoms), credentials (minimum permissions, preflight, rotation) and
troubleshooting (every message mapped to a category and a fix). README
capability table updated. MIL-002 records the verification of scoped
workflow support: Gitea 1.27.3 runs the workflow in this repository;
delivery to other repositories is not yet verified.

Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Task: MIL-002#7
Refs #9
Refs #10
Refs #11
Refs #12
Refs #13
Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 21:26:49 +08:00
co-authored by Claude Sonnet 5.5
parent 16b9060d4c
commit 7dcb9c4dfc
7 changed files with 361 additions and 30 deletions
@@ -29,6 +29,25 @@ jobs:
import urllib.request
class WorkflowError(RuntimeError):
"""A failure with a category.
configuration: a secret or repository setting is missing or malformed.
credentials: a service rejected the token (HTTP 401).
permissions: the token is valid but not allowed (HTTP 403).
api: any other API or network failure.
"""
def __init__(self, category, message):
super().__init__(f"[{category}] {message}")
self.category = category
def service_name(url):
hostname = urllib.parse.urlsplit(url).hostname
return "GitHub" if hostname == "api.github.com" else "Gitea"
def request_json(url, method="GET", headers=None, body=None):
request = urllib.request.Request(
url,
@@ -36,37 +55,64 @@ jobs:
headers=headers or {},
method=method,
)
service = service_name(url)
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})"
detail = (
f"{service} API request failed with HTTP "
f"{error.code} ({error.reason})"
)
if error.code == 401:
raise WorkflowError(
"credentials", f"{detail}. The token was rejected."
) from None
if error.code == 403:
raise WorkflowError(
"permissions",
f"{detail}. The token lacks a required permission.",
) from None
if error.code == 404:
raise WorkflowError(
"configuration",
f"{detail}. The repository was not found or the "
"token cannot see it.",
) from None
raise WorkflowError("api", detail) from None
except (urllib.error.URLError, TimeoutError) as error:
raise WorkflowError(
"api", f"{service} API is unreachable ({error})."
) from None
def parse_github_token(raw_credentials):
raw_credentials = (raw_credentials or "").strip()
if not raw_credentials:
raise RuntimeError("CREDENTIALS_FOR_GITHUB is missing or empty.")
raise WorkflowError(
"configuration", "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."
raise WorkflowError(
"configuration",
"CREDENTIALS_FOR_GITHUB must contain valid JSON.",
) from None
if not isinstance(credentials, dict):
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must be a JSON object."
raise WorkflowError(
"configuration",
"CREDENTIALS_FOR_GITHUB must be a JSON object.",
)
token = credentials.get("GITHUB_PAT")
if not isinstance(token, str) or not token.strip():
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT."
raise WorkflowError(
"configuration",
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.",
)
return token.strip()
@@ -74,7 +120,9 @@ jobs:
def parse_gitea_token(raw_credentials):
raw_credentials = (raw_credentials or "").strip()
if not raw_credentials:
raise RuntimeError("TOKEN_FOR_GITEA is missing or empty.")
raise WorkflowError(
"configuration", "TOKEN_FOR_GITEA is missing or empty."
)
try:
credentials = json.loads(raw_credentials)
@@ -89,8 +137,9 @@ jobs:
token = None
if not isinstance(token, str) or not token.strip():
raise RuntimeError(
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN."
raise WorkflowError(
"configuration",
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.",
)
return token.strip()
@@ -98,7 +147,10 @@ jobs:
def split_repository(full_name):
owner, separator, repo = (full_name or "").partition("/")
if not separator or not owner or not repo:
raise RuntimeError("Could not determine the Gitea source repository.")
raise WorkflowError(
"configuration",
"Could not determine the Gitea source repository.",
)
return owner, repo
@@ -117,16 +169,18 @@ jobs:
mirror_path = mirror_path.removesuffix(".git").strip("/")
path_parts = mirror_path.split("/")
if len(path_parts) != 2 or not all(path_parts):
raise RuntimeError(
raise WorkflowError(
"configuration",
"Could not determine the GitHub owner and repository "
"from a configured push mirror."
"from a configured push mirror.",
)
targets.append(tuple(path_parts))
if len(targets) != 1:
raise RuntimeError(
raise WorkflowError(
"configuration",
"Expected exactly one GitHub push mirror for this repository; "
f"found {len(targets)}."
f"found {len(targets)}.",
)
return targets[0]
@@ -169,6 +223,16 @@ jobs:
"Content-Type": "application/json",
}
github_repository = request_json(github_api_url, headers=github_headers)
permissions = (github_repository or {}).get("permissions")
if isinstance(permissions, dict) and not permissions.get("admin"):
raise WorkflowError(
"permissions",
f"The GitHub token cannot administer {github_owner}/{github_repo}; "
"editing the description and topics needs administration access.",
)
print("Preflight passed: Gitea and GitHub accepted the credentials.")
request_json(
github_api_url,
method="PATCH",