From 8d62ea0d56885cae9e08791641b86c62a2490fa5 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 10 Oct 2026 08:53:30 +0800 Subject: [PATCH] Generate the English wiki pages from README.md and guides/ 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 --- tests/test_build_wiki.py | 181 ++++++++++++++++++++++++++++++++ tools/build_wiki.py | 216 +++++++++++++++++++++++++++++++++++++++ wiki/Capabilities.md | 81 +++++++++++++++ wiki/Credentials.md | 68 ++++++++++++ wiki/Home.md | 56 ++++++++++ wiki/Onboarding.md | 76 ++++++++++++++ wiki/Troubleshooting.md | 68 ++++++++++++ 7 files changed, 746 insertions(+) create mode 100644 tests/test_build_wiki.py create mode 100644 tools/build_wiki.py create mode 100644 wiki/Capabilities.md create mode 100644 wiki/Credentials.md create mode 100644 wiki/Home.md create mode 100644 wiki/Onboarding.md create mode 100644 wiki/Troubleshooting.md diff --git a/tests/test_build_wiki.py b/tests/test_build_wiki.py new file mode 100644 index 0000000..5672616 --- /dev/null +++ b/tests/test_build_wiki.py @@ -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() == [] diff --git a/tools/build_wiki.py b/tools/build_wiki.py new file mode 100644 index 0000000..416fff0 --- /dev/null +++ b/tools/build_wiki.py @@ -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"\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()) diff --git a/wiki/Capabilities.md b/wiki/Capabilities.md new file mode 100644 index 0000000..776d02c --- /dev/null +++ b/wiki/Capabilities.md @@ -0,0 +1,81 @@ + + +> 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 +``` diff --git a/wiki/Credentials.md b/wiki/Credentials.md new file mode 100644 index 0000000..bf358d9 --- /dev/null +++ b/wiki/Credentials.md @@ -0,0 +1,68 @@ + + +> 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": ""} +``` + +## 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. diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..21c02b7 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,56 @@ + + +> 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. diff --git a/wiki/Onboarding.md b/wiki/Onboarding.md new file mode 100644 index 0000000..2409612 --- /dev/null +++ b/wiki/Onboarding.md @@ -0,0 +1,76 @@ + + +> 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 + /.` 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///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 | diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md new file mode 100644 index 0000000..7640b7f --- /dev/null +++ b/wiki/Troubleshooting.md @@ -0,0 +1,68 @@ + + +> 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: [] +``` + +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//.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 ` | 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 ; 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. -- 2.54.0