Merge pull request 'Generate the English wiki pages from README.md and guides/' (#43) from mil-005-english-wiki-pages into main
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -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() == []
|
||||
@@ -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())
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user