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..50cae24 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 | [0c06789] |
---
@@ -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
@@ -149,3 +149,4 @@ Proceed — the first milestone fixes a known defect in the only existing workfl
[SA-001]: ./stakeholder-analysis.md
[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d
+[0c06789]: https://git.tirsystem.com/TirSystem/github-action/commit/0c06789a2779382672712cd483f4964b28a0ebe9
diff --git a/docs/milestones/mil-001-stabilise-metadata-sync.md b/docs/milestones/mil-001-stabilise-metadata-sync.md
index c15865b..001ce3b 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 | [0c06789] |
---
@@ -62,16 +62,16 @@ 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
+[0c06789]: https://git.tirsystem.com/TirSystem/github-action/commit/0c06789a2779382672712cd483f4964b28a0ebe9
diff --git a/docs/project-plan.md b/docs/project-plan.md
index c86051e..339ae34 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 | [0c06789] |
---
@@ -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)
@@ -246,3 +246,4 @@ no tests, no release process.
[Milestone MIL-003]: https://git.tirsystem.com/TirSystem/github-action/milestone/20
[Milestone MIL-004]: https://git.tirsystem.com/TirSystem/github-action/milestone/21
[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d
+[0c06789]: https://git.tirsystem.com/TirSystem/github-action/commit/0c06789a2779382672712cd483f4964b28a0ebe9
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