From 0c06789a2779382672712cd483f4964b28a0ebe9 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 10 Oct 2026 07:57:58 +0800 Subject: [PATCH] Skip metadata sync successfully when no GitHub mirror exists Not every Gitea repository has a GitHub push mirror. Zero GitHub mirrors now ends the run successfully without reading the GitHub secret; several mirrors still fail. Tests, guides, README, BC-001, PP-001 and MIL-001 are updated to match. Task: MIL-001#1 Task: MIL-001#5 --- .../scoped_workflows/sync-github-metadata.yml | 16 ++++++++-- README.md | 4 ++- docs/business-case.md | 6 ++-- .../mil-001-stabilise-metadata-sync.md | 9 +++--- docs/project-plan.md | 10 +++--- guides/credentials.md | 3 +- guides/onboarding.md | 5 +-- guides/troubleshooting.md | 8 +++-- tests/test_sync_github_metadata.py | 31 +++++++++++++++---- 9 files changed, 64 insertions(+), 28 deletions(-) diff --git a/.gitea/scoped_workflows/sync-github-metadata.yml b/.gitea/scoped_workflows/sync-github-metadata.yml index b8dd267..6b23740 100644 --- a/.gitea/scoped_workflows/sync-github-metadata.yml +++ b/.gitea/scoped_workflows/sync-github-metadata.yml @@ -176,17 +176,18 @@ jobs: ) targets.append(tuple(path_parts)) + if not targets: + return None if len(targets) != 1: raise WorkflowError( "configuration", - "Expected exactly one GitHub push mirror for this repository; " + "Expected at most one GitHub push mirror for this repository; " f"found {len(targets)}.", ) return targets[0] def main(): - github_token = parse_github_token(os.environ.get("GITHUB_CREDENTIALS")) gitea_token = parse_gitea_token(os.environ.get("GITEA_CREDENTIALS")) source_owner, source_repo = split_repository( os.environ.get("SOURCE_REPOSITORY") @@ -210,7 +211,16 @@ jobs: ) return 0 - github_owner, github_repo = find_github_targets(mirrors) + target = find_github_targets(mirrors) + if target is None: + print( + "No GitHub push mirror configured for " + f"{source_owner}/{source_repo}; nothing to sync." + ) + return 0 + + github_token = parse_github_token(os.environ.get("GITHUB_CREDENTIALS")) + github_owner, github_repo = target github_api_url = ( "https://api.github.com/repos/" f"{urllib.parse.quote(github_owner, safe='')}/" diff --git a/README.md b/README.md index 19c795e..9c9ec10 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ It currently contains the Gitea workflow [`sync-github-metadata.yml`](.gitea/sco ## 📋 Requirements - A Gitea instance with Actions enabled and a runner providing `ubuntu-latest` with `python3` -- A configured GitHub push mirror on the Gitea repository (exactly one) +- 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 @@ -111,6 +111,8 @@ 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 diff --git a/docs/business-case.md b/docs/business-case.md index ebf5d22..3eee839 100644 --- a/docs/business-case.md +++ b/docs/business-case.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Rejected | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | -| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Added S05 to assumptions, risks and stakeholders | [4de5438] | +| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Added S05 to assumptions, risks and stakeholders | [4de5438] | +| 2026-10-10 | Accepted | Jens Tirsvad Nielsen | S01 | A mirrored repository has at most one GitHub push mirror; repositories without one are skipped | pending | --- @@ -116,7 +116,7 @@ accepted plans. ## Assumptions - The Gitea instance has Actions enabled and an `ubuntu-latest` runner with `python3`. -- Each mirrored repository has exactly one GitHub push mirror. +- A mirrored repository has at most one GitHub push mirror. Repositories without a GitHub mirror are skipped successfully. - S01 (Product Owner) and S05 (DevOps owner and security authority) are both maintainers. ## Constraints diff --git a/docs/milestones/mil-001-stabilise-metadata-sync.md b/docs/milestones/mil-001-stabilise-metadata-sync.md index c15865b..e2507a6 100644 --- a/docs/milestones/mil-001-stabilise-metadata-sync.md +++ b/docs/milestones/mil-001-stabilise-metadata-sync.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Recorded decisions for tasks 1 and 3 | [cbb9688] | +| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Recorded decisions for tasks 1 and 3 | [cbb9688] | +| 2026-10-10 | Accepted | Jens Tirsvad Nielsen | S01 | Task 1 decision amended: zero GitHub mirrors is a successful skip
Task 5 tests cover zero or several mirrors accordingly | pending | --- @@ -62,16 +62,15 @@ successful manual run on the Gitea instance. | # | Task | Summary | Needs its own Use Case/User Story? | Reference | | --- | --- | --- | --- | --- | -| 1 | Decide behavior for an invalid push mirror response | The committed workflow fails with an error when Gitea returns a non-list push mirror response. The uncommitted scoped copy instead prints a message and tries to exit successfully, and the README diagram showed that. Choose fail or skip, record the decision, and align code and README. Decision: skip with a visible warning and a successful run, following the maintainer's edit to the scoped copy; zero, several or malformed GitHub mirrors still fail. | No | | +| 1 | Decide behavior for an invalid push mirror response | The committed workflow fails with an error when Gitea returns a non-list push mirror response. The uncommitted scoped copy instead prints a message and tries to exit successfully, and the README diagram showed that. Choose fail or skip, record the decision, and align code and README. Decision: skip with a visible warning and a successful run, following the maintainer's edit to the scoped copy; several or malformed GitHub mirrors still fail. Amended 2026-10-10: zero GitHub mirrors is also a successful skip, because not every Gitea repository has a mirror; the GitHub secret is then not read. | No | | | 2 | Fix the Python syntax error in the scoped workflow | `exit 0` is not valid Python, so the whole embedded script fails to compile in the scoped copy. Replace it according to the decision in task 1 (`sys.exit(0)` also needs `import sys`). | No | | | 3 | Reduce the workflow to one canonical source | The same workflow exists in `src/` and `.gitea/scoped_workflows/` (and a legacy copy that is no longer documented), and the scoped copy has diverged. Choose the canonical location, and generate or remove the others. Decision: `.gitea/scoped_workflows/` is canonical because the script must be embedded in the YAML to work as a scoped workflow; the `src/` copy is removed. | No | | | 4 | Add static validation of workflow files | Parse each workflow YAML and compile its embedded Python in an automated check that runs on pull requests, so defects such as task 2 fail before merge. | No | | -| 5 | Add offline unit tests for the sync logic | Move credential and mirror URL parsing into testable code and test bare token, JSON token, SSH and HTTPS mirror addresses, and zero or several mirrors. Replace `tests/test_.ps1`, which probes a hard-coded real repository and reads `.env`. | No | | +| 5 | Add offline unit tests for the sync logic | Move credential and mirror URL parsing into testable code and test bare token, JSON token, SSH and HTTPS mirror addresses, zero mirrors (skipped successfully) and several mirrors (fail). Replace `tests/test_.ps1`, which probes a hard-coded real repository and reads `.env`. | No | | | 6 | Keep the README accurate | Maintain the capability table (implemented, partial, planned), and align the sequence diagram with the final behavior from task 1. | No | | | 7 | Verify a live run | Trigger the workflow manually on Gitea and compare the GitHub description and topics with the Gitea repository. | No | | --- [BC-001]: ../business-case.md -[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d [cbb9688]: https://git.tirsystem.com/TirSystem/github-action/commit/cbb9688ed4217ee953d5843b846e2e79f22aaf45 diff --git a/docs/project-plan.md b/docs/project-plan.md index c86051e..c6d8d5a 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Rejected | Jens Tirsvad Nielsen | S01 | Initial version
Added use case candidates and prioritisation appendix | [4de5438] | -| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner of every gateway is S05
Actors updated | [4de5438] | +| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Owner of every gateway is S05
Actors updated | [4de5438] | +| 2026-10-10 | Accepted | Jens Tirsvad Nielsen | S01 | UC candidate 1: no GitHub mirror is a successful skip | pending | --- @@ -117,10 +117,10 @@ no tests, no release process. - **Actor:** DevOps professional. **Goal:** the GitHub mirror shows the Gitea description and topics. - **Trigger:** push to `main`, manual dispatch, or daily at 03:17 UTC. -- **Preconditions:** both secrets set; exactly one GitHub push mirror configured; runner available. +- **Preconditions:** both secrets set; at most one GitHub push mirror configured (none: the run is skipped successfully); runner available. - **Success:** description and topics on GitHub equal those on Gitea. -- **Main flow:** the workflow validates the secrets, reads description and topics from Gitea, finds the single GitHub mirror, updates the description, replaces the topics. -- **Alternatives and failures:** missing or invalid secret: fail with a message; zero or several GitHub mirrors: fail; non-list mirror response: behavior undecided (MIL-001 task 1); Gitea or GitHub API error: fail with the HTTP code. +- **Main flow:** the workflow validates the secrets, reads description and topics from Gitea, finds the GitHub mirror (if none, ends successfully without syncing), updates the description, replaces the topics. +- **Alternatives and failures:** missing or invalid secret: fail with a message; no GitHub mirror: successful run, nothing synced; several GitHub mirrors: fail; non-list mirror response: behavior undecided (MIL-001 task 1); Gitea or GitHub API error: fail with the HTTP code. - **Dependencies:** none. ### UC candidate 2: Configure workflow credentials (P1) diff --git a/guides/credentials.md b/guides/credentials.md index 037fc29..81ba083 100644 --- a/guides/credentials.md +++ b/guides/credentials.md @@ -46,7 +46,8 @@ 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. Exactly one GitHub push mirror is configured. +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. diff --git a/guides/onboarding.md b/guides/onboarding.md index 4c7bd2e..5fca182 100644 --- a/guides/onboarding.md +++ b/guides/onboarding.md @@ -20,9 +20,10 @@ Until delivery is verified, onboard a repository by copying the workflow (below) 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 exactly one GitHub push mirror** in the repository settings +2. **Configure one GitHub push mirror** in the repository settings (Settings, Repository, Mirror settings, Push mirror). The workflow fails - when there are zero or several GitHub mirrors. + 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.md): - `TOKEN_FOR_GITEA` diff --git a/guides/troubleshooting.md b/guides/troubleshooting.md index aa49370..a368202 100644 --- a/guides/troubleshooting.md +++ b/guides/troubleshooting.md @@ -29,8 +29,7 @@ never starts, or stops before the step) is a **runner** problem. | `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 exactly one GitHub push mirror ... found 0` | configuration | Add a GitHub push mirror in the repository settings | -| `Expected exactly one GitHub push mirror ... found N` | configuration | Keep one GitHub push mirror | +| `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` | @@ -43,6 +42,11 @@ never starts, or stops before the step) is a **runner** problem. ## 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. diff --git a/tests/test_sync_github_metadata.py b/tests/test_sync_github_metadata.py index 14b7f37..5ff7d09 100644 --- a/tests/test_sync_github_metadata.py +++ b/tests/test_sync_github_metadata.py @@ -100,9 +100,8 @@ class TestFindGithubTargets: [{"remote_address": "https://gitlab.com/Org/other.git"}], ], ) - def test_fails_when_no_github_mirror_exists(self, sync, mirrors): - with pytest.raises(RuntimeError, match="found 0"): - sync["find_github_targets"](mirrors) + def test_returns_none_when_no_github_mirror_exists(self, sync, mirrors): + assert sync["find_github_targets"](mirrors) is None def test_fails_when_several_github_mirrors_exist(self, sync): mirrors = [ @@ -249,16 +248,36 @@ class TestMain: assert all(call[0] == "GET" for call in api.calls) assert "WARNING" in capsys.readouterr().out - def test_fails_before_any_request_when_credentials_are_missing( + @pytest.mark.parametrize( + "mirrors", + [ + [], + [{"remote_address": "https://gitlab.com/Org/other.git"}], + ], + ) + def test_succeeds_without_changes_when_no_github_mirror_exists( + self, sync, environment, monkeypatch, capsys, mirrors + ): + monkeypatch.setenv("GITHUB_CREDENTIALS", "") + api = FakeApi(mirrors) + sync["request_json"] = api + + assert sync["main"]() == 0 + + assert all(call[0] == "GET" for call in api.calls) + assert not any(call[1].startswith("https://api.github.com") for call in api.calls) + assert "No GitHub push mirror configured" in capsys.readouterr().out + + def test_fails_before_any_change_when_github_credentials_are_missing( self, sync, environment, monkeypatch ): monkeypatch.setenv("GITHUB_CREDENTIALS", "") - api = FakeApi([]) + api = FakeApi([{"remote_address": "git@github.com:Org/repo.git"}]) sync["request_json"] = api with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"): sync["main"]() - assert api.calls == [] + assert all(call[0] == "GET" for call in api.calls) def test_stops_before_any_change_when_github_token_cannot_administer( self, sync, environment