Generate the English wiki pages from README.md and guides/
Validate workflows / validate (pull_request) Successful in 13s

Add tools/build_wiki.py, which builds the five English wiki pages into
wiki/, rewrites links so they work inside the wiki and reports pages that
differ from their sources. Offline tests cover the conversion and assert
that the committed pages are current.

Task: MIL-005#3
This commit is contained in:
2026-10-10 08:53:30 +08:00
parent bef58e75c7
commit 8d62ea0d56
7 changed files with 746 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
"""Offline tests for tools/build_wiki.py."""
import importlib.util
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
_SPEC = importlib.util.spec_from_file_location(
"build_wiki", REPO_ROOT / "tools" / "build_wiki.py"
)
assert _SPEC is not None and _SPEC.loader is not None
build_wiki = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(build_wiki)
BASE = build_wiki.SOURCE_BASE_URL
class TestStripHeadingSymbols:
@pytest.mark.parametrize(
("line", "expected"),
[
("## 🧭 Overview", "## Overview"),
("## 🛠️ Setup", "## Setup"),
("## ▶️ Run", "## Run"),
("#### 🕒 Cron", "#### Cron"),
("## Plain", "## Plain"),
("## `code` heading", "## `code` heading"),
],
)
def test_removes_leading_emoji_only(self, line, expected):
assert build_wiki.strip_heading_symbols(line) == expected
class TestRewriteLink:
@pytest.mark.parametrize(
("target", "source", "expected"),
[
("guides/credentials.md", "README.md", "Credentials"),
("guides/onboarding.md#runners", "README.md", "Onboarding#runners"),
("troubleshooting.md", "guides/credentials.md", "Troubleshooting"),
("onboarding.md#runners", "guides/troubleshooting.md", "Onboarding#runners"),
("#-requirements", "README.md", "#requirements"),
("#runners", "guides/onboarding.md", "#runners"),
("https://example.test/a", "README.md", "https://example.test/a"),
("mailto:a@example.test", "README.md", "mailto:a@example.test"),
("docs/project-plan.md", "README.md", f"{BASE}/docs/project-plan.md"),
("../docs/project-plan.md", "guides/onboarding.md", f"{BASE}/docs/project-plan.md"),
(
".gitea/scoped_workflows/sync-github-metadata.yml",
"README.md",
f"{BASE}/.gitea/scoped_workflows/sync-github-metadata.yml",
),
("../README.md", "guides/onboarding.md", "Home"),
],
)
def test_rewrites_by_target_kind(self, target, source, expected):
assert build_wiki.rewrite_link(target, source) == expected
class TestConvertMarkdown:
def test_leaves_fenced_code_untouched(self):
text = "## 🧭 Title\n```bash\n## 🧭 not a heading\n[x](guides/a.md)\n```\n"
converted = build_wiki.convert_markdown(text, "README.md")
assert converted.splitlines()[0] == "## Title"
assert "## 🧭 not a heading" in converted
assert "[x](guides/a.md)" in converted
def test_rewrites_links_outside_code(self):
text = "See [guide](guides/credentials.md) and [plan](docs/project-plan.md)."
converted = build_wiki.convert_markdown(text, "README.md")
assert f"[guide](Credentials) and [plan]({BASE}/docs/project-plan.md)" in converted
class TestSplitH2Sections:
def test_keeps_subsections_and_ignores_h2_in_code(self):
text = (
"# Title\nintro\n"
"## 🧭 One\na\n### Sub\nb\n"
"## Two\n```\n## not a section\n```\n"
)
preamble, sections = build_wiki.split_h2_sections(text)
assert preamble == ["# Title", "intro"]
assert list(sections) == ["One", "Two"]
assert "### Sub" in sections["One"]
assert "## not a section" in sections["Two"]
def _write_sources(root: Path) -> None:
(root / "guides").mkdir()
(root / "README.md").write_text(
"# 🚀 Name\nIntro.\n\n## 📚 Table of Contents\n- [x](#-overview)\n\n"
"## 🧭 Overview\nSee [credentials](guides/credentials.md).\n\n"
"## 📋 Requirements\nNeeds a runner.\n\n## 🛠️ Setup\nRun [it](#-requirements).\n\n"
"## ▶️ Run\nGo.\n\n## 🧪 Tests\nPytest.\n\n## 🟢 Capabilities\nA table.\n\n"
"### 🔄 Sync\nDetails.\n\n## 📄 License\nMIT.\n",
encoding="utf-8",
)
for name in ("onboarding", "credentials", "troubleshooting"):
(root / "guides" / f"{name}.md").write_text(
f"# {name.title()}\nBody.\n", encoding="utf-8"
)
class TestRenderPage:
@pytest.fixture()
def repo(self, tmp_path):
_write_sources(tmp_path)
return tmp_path
def test_home_keeps_overview_to_tests_and_drops_the_rest(self, repo):
home = build_wiki.render_page(build_wiki.PAGES[0], repo)
assert "# Name" in home
assert "## Overview" in home
assert "[credentials](Credentials)" in home
assert "Run [it](#requirements)." in home
assert "Table of Contents" not in home
assert "Capabilities" not in home
assert "License" not in home
def test_capabilities_page_keeps_its_subsections(self, repo):
page = next(p for p in build_wiki.PAGES if p.name == "Capabilities")
text = build_wiki.render_page(page, repo)
assert "## Capabilities" in text
assert "### Sync" in text
assert "## Overview" not in text
def test_every_page_names_its_source_and_warns(self, repo):
for page in build_wiki.PAGES:
text = build_wiki.render_page(page, repo)
assert f"Generated from {page.source}" in text
assert "overwritten" in text
def test_missing_section_is_an_error(self, repo):
(repo / "README.md").write_text("# Name\n", encoding="utf-8")
with pytest.raises(build_wiki.WikiBuildError, match="section 'Overview'"):
build_wiki.render_page(build_wiki.PAGES[0], repo)
def test_missing_source_is_an_error(self, repo):
(repo / "guides" / "onboarding.md").unlink()
page = next(p for p in build_wiki.PAGES if p.name == "Onboarding")
with pytest.raises(build_wiki.WikiBuildError, match="is missing"):
build_wiki.render_page(page, repo)
class TestWriteAndCheck:
@pytest.fixture()
def repo(self, tmp_path):
root = tmp_path / "repo"
root.mkdir()
_write_sources(root)
return root
def test_write_then_check_passes(self, repo, tmp_path):
wiki = tmp_path / "wiki"
changed = build_wiki.write_pages(wiki, repo)
assert sorted(changed) == sorted(f"{p.name}.md" for p in build_wiki.PAGES)
assert build_wiki.check_pages(wiki, repo) == []
assert build_wiki.write_pages(wiki, repo) == []
def test_check_reports_missing_and_changed_pages(self, repo, tmp_path):
wiki = tmp_path / "wiki"
build_wiki.write_pages(wiki, repo)
(wiki / "Home.md").write_text("edited by hand\n", encoding="utf-8")
(wiki / "Credentials.md").unlink()
problems = build_wiki.check_pages(wiki, repo)
assert "Home.md: differs from its source" in problems
assert "Credentials.md: missing" in problems
def test_check_detects_a_changed_source(self, repo, tmp_path):
wiki = tmp_path / "wiki"
build_wiki.write_pages(wiki, repo)
(repo / "guides" / "onboarding.md").write_text("# Changed\n", encoding="utf-8")
assert build_wiki.check_pages(wiki, repo) == [
"Onboarding.md: differs from its source"
]
def test_committed_wiki_pages_are_current():
"""The pages in wiki/ match what README.md and guides/ produce."""
assert build_wiki.check_pages() == []
+216
View File
@@ -0,0 +1,216 @@
"""Build the English wiki pages in ``wiki/`` from ``README.md`` and ``guides/``.
The wiki source lives in this repository (MIL-005). The English pages are
generated, never edited by hand; ``--check`` reports a page that differs from
what its sources produce.
Usage:
python tools/build_wiki.py # write the pages
python tools/build_wiki.py --check # exit 1 when a page is out of date
"""
import argparse
import posixpath
import re
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
WIKI_DIR = REPO_ROOT / "wiki"
SOURCE_BASE_URL = "https://git.tirsystem.com/TirSystem/github-action/src/branch/main"
GENERATOR = "tools/build_wiki.py"
# Leading emoji and symbols in README headings; removed so anchors stay stable.
_HEADING_SYMBOLS = re.compile(r"^(#{1,6})[ \t]+[←-\U0001FAFF️‍]+[ \t]*")
_FENCE = re.compile(r"^[ \t]*(```|~~~)")
_LINK_TARGET = re.compile(r"\]\(([^)\s]+)\)")
_H2 = re.compile(r"^##[ \t]+(.*?)[ \t]*$")
@dataclass(frozen=True)
class Page:
"""One generated wiki page."""
name: str
source: str
sections: tuple[str, ...] | None = None # H2 titles to keep; None keeps the file
PAGES: tuple[Page, ...] = (
Page("Home", "README.md", ("Overview", "Requirements", "Setup", "Run", "Tests")),
Page("Onboarding", "guides/onboarding.md"),
Page("Credentials", "guides/credentials.md"),
Page("Troubleshooting", "guides/troubleshooting.md"),
Page("Capabilities", "README.md", ("Capabilities",)),
)
# The page a link to a source file should point to. The README is the home page.
_PAGE_FOR_SOURCE = {
"README.md": "Home",
"guides/onboarding.md": "Onboarding",
"guides/credentials.md": "Credentials",
"guides/troubleshooting.md": "Troubleshooting",
}
class WikiBuildError(RuntimeError):
"""A source file or section needed for a page is missing."""
def strip_heading_symbols(line: str) -> str:
"""Remove the leading emoji from a heading line."""
return _HEADING_SYMBOLS.sub(r"\1 ", line)
def rewrite_link(target: str, source: str) -> str:
"""Return the wiki form of a link target found in ``source``.
Links to another generated page become wiki page links. Links to other
repository files become absolute links to the file on ``main``, because the
wiki has no copy of them. External links are unchanged.
"""
if re.match(r"^[a-z][a-z0-9+.-]*:", target, re.IGNORECASE):
return target
path, hash_mark, fragment = target.partition("#")
if not path:
return "#" + fragment.lstrip("-")
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source), path))
suffix = f"#{fragment}" if hash_mark else ""
page = _PAGE_FOR_SOURCE.get(resolved)
if page is not None:
return page + suffix
return f"{SOURCE_BASE_URL}/{resolved}{suffix}"
def convert_markdown(text: str, source: str) -> str:
"""Strip heading emoji and rewrite links, leaving fenced code untouched."""
out: list[str] = []
in_fence = False
for line in text.splitlines():
if _FENCE.match(line):
in_fence = not in_fence
out.append(line)
continue
if in_fence:
out.append(line)
continue
line = strip_heading_symbols(line)
line = _LINK_TARGET.sub(
lambda match: f"]({rewrite_link(match.group(1), source)})", line
)
out.append(line)
return "\n".join(out)
def split_h2_sections(text: str) -> tuple[list[str], dict[str, list[str]]]:
"""Return the lines before the first H2 and the lines of each H2 section.
A section keeps its heading line and everything up to the next H2, so its
H3 and deeper headings stay with it. Fenced code is not scanned.
"""
preamble: list[str] = []
sections: dict[str, list[str]] = {}
current: list[str] = preamble
in_fence = False
for line in text.splitlines():
if _FENCE.match(line):
in_fence = not in_fence
match = None if in_fence else _H2.match(line)
if match:
title = _HEADING_SYMBOLS.sub("## ", line)[3:].strip()
current = sections.setdefault(title, [])
current.append(line)
return preamble, sections
def render_page(page: Page, repo_root: Path = REPO_ROOT) -> str:
"""Return the full Markdown of one wiki page."""
source_path = repo_root / page.source
try:
text = source_path.read_text(encoding="utf-8")
except FileNotFoundError:
raise WikiBuildError(f"{page.name}: source {page.source} is missing") from None
if page.sections is None:
body_lines = text.splitlines()
else:
preamble, sections = split_h2_sections(text)
body_lines = list(preamble) if page.name == "Home" else []
for title in page.sections:
if title not in sections:
raise WikiBuildError(
f"{page.name}: section '{title}' not found in {page.source}"
)
body_lines += [""] + sections[title]
banner = (
f"<!-- Generated from {page.source} by {GENERATOR}. "
"Edit the source, not this page. -->\n\n"
f"> This page is generated from [`{page.source}`]"
f"({SOURCE_BASE_URL}/{page.source}). Changes made here are overwritten "
"the next time the wiki is published.\n\n"
)
body = convert_markdown("\n".join(body_lines), page.source).strip("\n")
return banner + body + "\n"
def build_all(repo_root: Path = REPO_ROOT) -> dict[str, str]:
"""Return ``{file name: content}`` for every English page."""
return {f"{page.name}.md": render_page(page, repo_root) for page in PAGES}
def write_pages(wiki_dir: Path = WIKI_DIR, repo_root: Path = REPO_ROOT) -> list[str]:
"""Write every page; return the names of the files that changed."""
changed: list[str] = []
wiki_dir.mkdir(parents=True, exist_ok=True)
for name, content in build_all(repo_root).items():
path = wiki_dir / name
current = path.read_bytes() if path.exists() else None
if current != content.encode("utf-8"):
path.write_bytes(content.encode("utf-8"))
changed.append(name)
return changed
def check_pages(wiki_dir: Path = WIKI_DIR, repo_root: Path = REPO_ROOT) -> list[str]:
"""Return a message for every page that is missing or out of date."""
problems: list[str] = []
for name, content in build_all(repo_root).items():
path = wiki_dir / name
if not path.exists():
problems.append(f"{name}: missing")
elif path.read_bytes() != content.encode("utf-8"):
problems.append(f"{name}: differs from its source")
return problems
def main(argv: list[str] | None = None) -> int:
"""Run the generator; return the process exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check",
action="store_true",
help="report pages that differ from their sources and exit 1",
)
args = parser.parse_args(argv)
try:
if args.check:
problems = check_pages()
for problem in problems:
print(f"ERROR: wiki/{problem}", file=sys.stderr)
if problems:
print(f"Run `python {GENERATOR}` and commit wiki/.", file=sys.stderr)
return 1
print("Wiki pages are up to date.")
return 0
changed = write_pages()
except WikiBuildError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(f"Wrote {len(changed)} page(s): {', '.join(changed) or 'none changed'}")
return 0
if __name__ == "__main__":
sys.exit(main())
+81
View File
@@ -0,0 +1,81 @@
<!-- Generated from README.md by tools/build_wiki.py. Edit the source, not this page. -->
> This page is generated from [`README.md`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/README.md). Changes made here are overwritten the next time the wiki is published.
## Capabilities
Only the first row is implemented. The rest is planned and tracked in the [Project Plan](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/docs/project-plan.md), which also describes each use case.
| Capability | Status |
| --- | --- |
| Synchronize repository metadata (Gitea to GitHub mirror) | Implemented in `.gitea/scoped_workflows/`, with offline tests and a validation workflow |
| Configure and validate workflow credentials | Implemented for the sync workflow: preflight checks both tokens before any change ([guide](Credentials)) |
| Onboard a repository, manage runners, diagnose failures | Guides written ([onboarding](Onboarding), [troubleshooting](Troubleshooting)); scoped delivery to other repositories not yet verified; brief use cases open (MIL-002) |
| Shared quality checks | Planned (MIL-003) |
| Versioned release and publishing | Planned (MIL-004) |
### Sync GitHub metadata
Syncs the Gitea repository description and topics to the GitHub push mirror.
#### Cron
Daily at 03:17 UTC (`17 3 * * *`), plus every push to `main` and manual `workflow_dispatch`.
#### Workflow
```plantuml
@startuml
title Sync Gitea repository description and topics to GitHub
autonumber
actor "Push to main,\nmanual trigger, or schedule" as Trigger
participant "Gitea Actions\nsync-github-metadata" as Workflow
participant "Gitea REST API" as Gitea
participant "GitHub REST API" as GitHub
Trigger -> Workflow: Start workflow
activate Workflow
Workflow -> Workflow: Read and validate secrets
note right
TOKEN_FOR_GITEA
CREDENTIALS_FOR_GITHUB
end note
alt secret validation failed
Workflow --> Trigger: workflow failed
end
Workflow -> Gitea: GET /repos/{owner}/{repo}
Gitea --> Workflow: Description and topics
Workflow -> Gitea: GET /repos/{owner}/{repo}/push_mirrors
Gitea --> Workflow: Push mirror response
alt mirrors is not a list
Workflow --> Trigger: Log a warning and succeed\n(GitHub metadata not synced)
else mirrors is a list with no GitHub mirror
Workflow --> Trigger: Log that there is nothing to sync and succeed
else mirrors is a list
Workflow -> Workflow: Find GitHub owner and repo from remote_address
Workflow -> GitHub: PATCH /repos/{owner}/{repo}\n{description}
GitHub --> Workflow: Repository updated
Workflow -> GitHub: PUT /repos/{owner}/{repo}/topics\n{names}
GitHub --> Workflow: Topics updated
Workflow --> Trigger: Sync completed
else Gitea API denies access
Gitea --> Workflow: HTTP 403 Forbidden
Workflow --> Trigger: Workflow fails
else GitHub API denies access
GitHub --> Workflow: HTTP error
Workflow --> Trigger: Workflow fails
end
deactivate Workflow
@enduml
```
+68
View File
@@ -0,0 +1,68 @@
<!-- Generated from guides/credentials.md by tools/build_wiki.py. Edit the source, not this page. -->
> This page is generated from [`guides/credentials.md`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/guides/credentials.md). Changes made here are overwritten the next time the wiki is published.
# Workflow credentials
The sync workflow uses two secrets. Give each the least privilege that works,
store it only as a Gitea secret, and rotate it when a person with access
leaves.
| Secret | Content | Used for |
| --- | --- | --- |
| `TOKEN_FOR_GITEA` | A bare token, a JSON string, or JSON with `GITEA_TOKEN` | Reading the repository (description, topics) and its push mirrors |
| `CREDENTIALS_FOR_GITHUB` | JSON object with a non-empty `GITHUB_PAT` | Changing the description and topics of the GitHub mirror |
## Minimum permissions
These come from the Gitea and GitHub documentation and the API calls the
workflow makes. They have **not been confirmed with a minimal token on this
instance yet**; S05 should confirm them when the tokens are created.
**Gitea token** (a personal access token of a user, not a deploy key; deploy
keys do not work for the REST API):
- scope `read:repository`
- the user must be allowed to read the repository's push mirrors, which
Gitea limits to repository administrators
Calls made: `GET /repos/{owner}/{repo}` and `GET /repos/{owner}/{repo}/push_mirrors`.
**GitHub token** (fine-grained personal access token):
- Resource owner: the owner of the mirror repository
- Repository access: only the mirror repository
- Repository permission: **Administration: Read and write** (no other permission)
- Set an expiry date
Calls made: `GET /repos/{owner}/{repo}`, `PATCH /repos/{owner}/{repo}` (description)
and `PUT /repos/{owner}/{repo}/topics`.
Format of `CREDENTIALS_FOR_GITHUB`:
```json
{"GITHUB_PAT": "<token>"}
```
## What the workflow checks before it changes anything
The workflow runs a preflight and writes nothing to GitHub unless it passes:
1. Both secrets exist and have the expected shape.
2. Gitea accepts the token and the repository is visible with it.
3. At most one GitHub push mirror is configured. With none, the run ends
successfully without syncing and the GitHub secret is not read.
4. GitHub accepts the token for the mirror repository **and** the token
reports administration access to it.
On success the log says `Preflight passed: Gitea and GitHub accepted the
credentials.` Failures start with a category such as `[credentials]` or
`[permissions]`; see [troubleshooting.md](Troubleshooting). No secret value
is ever printed.
## Rotating a token
1. Create the new token with the permissions above.
2. Replace the secret value in the repository or organization settings.
3. Run the workflow manually and wait for `Preflight passed`.
4. Revoke the old token.
+56
View File
@@ -0,0 +1,56 @@
<!-- Generated from README.md by tools/build_wiki.py. Edit the source, not this page. -->
> This page is generated from [`README.md`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/README.md). Changes made here are overwritten the next time the wiki is published.
# TirSystem GitHub Actions
A collection of reusable Gitea Actions workflows for DevOps professionals and software engineers working on TirSystem projects.
## Overview
This repository is the central collection of reusable automation for TirSystem repositories. The primary repository lives at `git.tirsystem.com` (Gitea) and is push-mirrored to GitHub.
It currently contains the Gitea workflow [`sync-github-metadata.yml`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/.gitea/scoped_workflows/sync-github-metadata.yml), which copies the repository description and topics from Gitea to the GitHub mirror on every push to `main`, on manual dispatch, and daily at 03:17 UTC.
## Requirements
- A Gitea instance with Actions enabled and a runner providing `ubuntu-latest` with `python3`
- A configured GitHub push mirror on the Gitea repository (at most one; repositories without a mirror are skipped successfully)
- Repository secrets:
- `TOKEN_FOR_GITEA`: Gitea API token (plain string, or JSON with `GITEA_TOKEN`)
- `CREDENTIALS_FOR_GITHUB`: JSON object containing `GITHUB_PAT`, a GitHub personal access token allowed to edit the mirror repository
## Setup
No dependencies need to be installed; the workflow uses only the Python standard library.
1. Clone the repository:
```bash
git clone ssh://git@git.tirsystem.com:10022/TirSystem/github-action.git
```
2. Add the secrets listed under [Requirements](#requirements) to the repository settings in Gitea.
3. Configure the GitHub push mirror in the repository settings.
## Run
Workflows run automatically on push to `main` and on the daily schedule. To run manually, trigger **Sync GitHub mirror metadata** via `workflow_dispatch` from the Actions tab in Gitea.
## Tests
The workflow files and the Python embedded in them are validated by offline tests that need no network access and no secrets:
```bash
python3 -m venv .venv
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install -r tests/requirements.txt
python -m pytest -q tests
```
The same checks run on every pull request and push to `main` through [`validate-workflows.yml`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/.gitea/workflows/validate-workflows.yml). They parse every workflow as YAML, compile the embedded Python, require explicit job permissions, and test credential parsing, push mirror address parsing and the sync flow against a fake API.
After a change to the sync workflow, also trigger **Sync GitHub mirror metadata** manually in Gitea and compare the description and topics on the GitHub mirror.
+76
View File
@@ -0,0 +1,76 @@
<!-- Generated from guides/onboarding.md by tools/build_wiki.py. Edit the source, not this page. -->
> This page is generated from [`guides/onboarding.md`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/guides/onboarding.md). Changes made here are overwritten the next time the wiki is published.
# Onboarding a repository
How a DevOps professional makes a TirSystem repository use the shared
**Sync GitHub mirror metadata** workflow, and which runners it needs. Only
this workflow exists today; the quality-check and release workflows are
planned (see the [Project Plan](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/docs/project-plan.md)).
## What has been verified
| Item | Status |
| --- | --- |
| Gitea version | 1.27.3 |
| `.gitea/scoped_workflows/sync-github-metadata.yml` runs in this repository | Verified: run 16, `workflow_dispatch`, succeeded |
| The same workflow applies to other repositories automatically | **Not verified.** Confirm how scoped workflows are delivered on the instance (admin or organization settings) before relying on it. |
Until delivery is verified, onboard a repository by copying the workflow (below).
## Steps
1. **Check the prerequisites** in the repository: Actions are enabled, and a
runner with the label `ubuntu-latest` and `python3` is online (see
[Runners](#runners)).
2. **Configure one GitHub push mirror** in the repository settings
(Settings, Repository, Mirror settings, Push mirror). The workflow fails
when there are several GitHub mirrors. With none, it succeeds without
syncing anything.
3. **Add the two secrets** in the repository (or organization) settings,
using the scopes in [credentials.md](Credentials):
- `TOKEN_FOR_GITEA`
- `CREDENTIALS_FOR_GITHUB` (JSON with `GITHUB_PAT`)
4. **Deliver the workflow:**
- *If scoped workflows apply to the repository:* nothing to copy.
- *Otherwise:* copy `.gitea/scoped_workflows/sync-github-metadata.yml`
from this repository to `.gitea/workflows/` in the target repository.
Pin the copy to a reviewed version of this repository and re-copy
when it changes.
5. **Set a description and topics** on the Gitea repository, so there is
something to sync.
6. **Run it once manually:** Actions, **Sync GitHub mirror metadata**,
Run workflow. The log must end with `Synced description and topics to
<owner>/<repo>.` and the GitHub repository must show the same description
and topics.
7. If it fails, use [troubleshooting.md](Troubleshooting).
## Runners
| Item | Value |
| --- | --- |
| Label the workflow uses | `ubuntu-latest` |
| Labels offered by the runner on this instance | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` |
| Tools the workflow needs | `python3` (standard library only), outbound HTTPS to the Gitea API and `api.github.com` |
| Needed for `actions/checkout` (used by the validation workflow) | `git` and Node.js, normally in the runner image |
**Check that a suitable runner is online** (needs permission to view runners):
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.tirsystem.com/api/v1/repos/<owner>/<repo>/actions/runners
```
For runners shared by the whole instance, an administrator uses
`/api/v1/admin/actions/runners`. At the time of writing the repository and
organization lists are empty and one instance-level runner (`36cd20104923`)
is `online`.
**Offline or incompatible runner**
| Symptom | Likely cause | Action |
| --- | --- | --- |
| Run stays `waiting` | No online runner has the label in `runs-on` | Start the runner, or register one with the label |
| Job fails at the start with a missing command | Runner image lacks `python3`, `git` or Node.js | Use an image that has them |
| Job cannot reach the API | Runner has no outbound HTTPS | Fix runner network or proxy settings |
+68
View File
@@ -0,0 +1,68 @@
<!-- Generated from guides/troubleshooting.md by tools/build_wiki.py. Edit the source, not this page. -->
> This page is generated from [`guides/troubleshooting.md`](https://git.tirsystem.com/TirSystem/github-action/src/branch/main/guides/troubleshooting.md). Changes made here are overwritten the next time the wiki is published.
# Diagnosing a failed run
Open the run in Gitea (Actions) and read the last lines of the failing step.
Every failure from the sync script is written as
```
ERROR: [<category>] <message>
```
The category tells you where to look. A failure outside the script (the job
never starts, or stops before the step) is a **runner** problem.
| Category | Meaning | First thing to check |
| --- | --- | --- |
| `configuration` | A secret or a repository setting is missing or malformed | Secret names and format, push mirror setup |
| `credentials` | A service rejected the token (HTTP 401) | Token expired, revoked or mistyped |
| `permissions` | The token is valid but not allowed (HTTP 403, or no GitHub administration access) | Token scopes and repository access |
| `api` | Another HTTP error, or the service was unreachable | Service status, runner network |
| runner | No `ERROR:` line; the run waits, or the job fails before the script | Runner online, label, tools |
## Messages and fixes
| Message (start) | Category | Cause and fix |
| --- | --- | --- |
| `CREDENTIALS_FOR_GITHUB is missing or empty.` | configuration | Add the secret |
| `CREDENTIALS_FOR_GITHUB must contain valid JSON.` | configuration | The value must be JSON such as `{"GITHUB_PAT": "..."}` |
| `CREDENTIALS_FOR_GITHUB must be a JSON object.` | configuration | Use an object, not a list or string |
| `CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.` | configuration | Add the `GITHUB_PAT` key with a token |
| `TOKEN_FOR_GITEA is missing or empty.` | configuration | Add the secret |
| `TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.` | configuration | The JSON form needs a `GITEA_TOKEN` key |
| `Could not determine the Gitea source repository.` | configuration | The run has no repository name; report as a defect |
| `Expected at most one GitHub push mirror ... found N` | configuration | Keep one GitHub push mirror |
| `Could not determine the GitHub owner and repository ...` | configuration | The mirror address must look like `https://github.com/<owner>/<repo>.git` |
| `Gitea API request failed with HTTP 401` | credentials | Create a new Gitea token and update `TOKEN_FOR_GITEA` |
| `GitHub API request failed with HTTP 401` | credentials | Create a new GitHub token and update `CREDENTIALS_FOR_GITHUB` |
| `... HTTP 403 ...` (Gitea) | permissions | Token needs `read:repository`, and its user needs administrator access to the repository |
| `... HTTP 403 ...` (GitHub) | permissions | Token needs Administration read and write on the mirror repository |
| `The GitHub token cannot administer <repo>` | permissions | Same as above; the token can read the repository but not edit it |
| `... HTTP 404 ...` | configuration | Repository not found, or the token cannot see it; check the mirror address and token repository access |
| `... HTTP 5xx ...` | api | Service problem; re-run later |
| `... API is unreachable ...` | api | Network, DNS or proxy problem on the runner |
## Not an error
`No GitHub push mirror configured for <repo>; nothing to sync.` The run
succeeds. Not every Gitea repository has a GitHub mirror, so this is expected
for those. If the repository should have one, add it in the repository
settings.
`WARNING: Gitea returned an invalid push mirror list; GitHub metadata was not
synced.` The run succeeds but nothing was synced. Check that the Gitea
version supports the push mirror API and re-run.
## Runner problems
See the [Runners](Onboarding#runners) section. In short: if the run stays
`waiting`, no online runner has the label; if the job fails before the
script, the runner image is missing a tool.
## Re-running
Use **Run workflow** (`workflow_dispatch`) after fixing the cause. The sync is
safe to repeat: it overwrites the GitHub description and topics with the
Gitea values.