Skip the metadata sync successfully when no GitHub mirror exists #28

Merged
Tirsvad merged 2 commits from skip-sync-without-github-mirror into main 2026-10-10 01:58:54 +02:00
9 changed files with 67 additions and 28 deletions
@@ -176,17 +176,18 @@ jobs:
) )
targets.append(tuple(path_parts)) targets.append(tuple(path_parts))
if not targets:
return None
if len(targets) != 1: if len(targets) != 1:
raise WorkflowError( raise WorkflowError(
"configuration", "configuration",
"Expected exactly one GitHub push mirror for this repository; " "Expected at most one GitHub push mirror for this repository; "
f"found {len(targets)}.", f"found {len(targets)}.",
) )
return targets[0] return targets[0]
def main(): def main():
github_token = parse_github_token(os.environ.get("GITHUB_CREDENTIALS"))
gitea_token = parse_gitea_token(os.environ.get("GITEA_CREDENTIALS")) gitea_token = parse_gitea_token(os.environ.get("GITEA_CREDENTIALS"))
source_owner, source_repo = split_repository( source_owner, source_repo = split_repository(
os.environ.get("SOURCE_REPOSITORY") os.environ.get("SOURCE_REPOSITORY")
@@ -210,7 +211,16 @@ jobs:
) )
return 0 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 = ( github_api_url = (
"https://api.github.com/repos/" "https://api.github.com/repos/"
f"{urllib.parse.quote(github_owner, safe='')}/" f"{urllib.parse.quote(github_owner, safe='')}/"
+3 -1
View File
@@ -21,7 +21,7 @@ It currently contains the Gitea workflow [`sync-github-metadata.yml`](.gitea/sco
## 📋 Requirements ## 📋 Requirements
- A Gitea instance with Actions enabled and a runner providing `ubuntu-latest` with `python3` - 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: - Repository secrets:
- `TOKEN_FOR_GITEA`: Gitea API token (plain string, or JSON with `GITEA_TOKEN`) - `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 - `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 alt mirrors is not a list
Workflow --> Trigger: Log a warning and succeed\n(GitHub metadata not synced) 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 else mirrors is a list
Workflow -> Workflow: Find GitHub owner and repo from remote_address Workflow -> Workflow: Find GitHub owner and repo from remote_address
+4 -3
View File
@@ -9,8 +9,8 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-03 | Rejected | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | | 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Added S05 to assumptions, risks and stakeholders | [4de5438] |
| 2026-10-03 | Accepted | 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 ## Assumptions
- The Gitea instance has Actions enabled and an `ubuntu-latest` runner with `python3`. - 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. - S01 (Product Owner) and S05 (DevOps owner and security authority) are both maintainers.
## Constraints ## Constraints
@@ -149,3 +149,4 @@ Proceed — the first milestone fixes a known defect in the only existing workfl
[SA-001]: ./stakeholder-analysis.md [SA-001]: ./stakeholder-analysis.md
[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d [4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d
[0c06789]: https://git.tirsystem.com/TirSystem/github-action/commit/0c06789a2779382672712cd483f4964b28a0ebe9
@@ -9,8 +9,8 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | 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 | Deprecated | Jens Tirsvad Nielsen | S01 | Recorded decisions for tasks 1 and 3 | [cbb9688] |
| 2026-10-03 | Proposed | 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<br>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 | | # | 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 | | | 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 | | | 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 | | | 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 | | | 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 | | | 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 [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 [cbb9688]: https://git.tirsystem.com/TirSystem/github-action/commit/cbb9688ed4217ee953d5843b846e2e79f22aaf45
[0c06789]: https://git.tirsystem.com/TirSystem/github-action/commit/0c06789a2779382672712cd483f4964b28a0ebe9
+6 -5
View File
@@ -9,8 +9,8 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-03 | Rejected | Jens Tirsvad Nielsen | S01 | Initial version<br>Added use case candidates and prioritisation appendix | [4de5438] | | 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Owner of every gateway is S05<br>Actors updated | [4de5438] |
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner of every gateway is S05<br>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. - **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. - **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. - **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. - **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; 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. - **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. - **Dependencies:** none.
### UC candidate 2: Configure workflow credentials (P1) ### 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-003]: https://git.tirsystem.com/TirSystem/github-action/milestone/20
[Milestone MIL-004]: https://git.tirsystem.com/TirSystem/github-action/milestone/21 [Milestone MIL-004]: https://git.tirsystem.com/TirSystem/github-action/milestone/21
[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d [4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d
[0c06789]: https://git.tirsystem.com/TirSystem/github-action/commit/0c06789a2779382672712cd483f4964b28a0ebe9
+2 -1
View File
@@ -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. 1. Both secrets exist and have the expected shape.
2. Gitea accepts the token and the repository is visible with it. 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 4. GitHub accepts the token for the mirror repository **and** the token
reports administration access to it. reports administration access to it.
+3 -2
View File
@@ -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 1. **Check the prerequisites** in the repository: Actions are enabled, and a
runner with the label `ubuntu-latest` and `python3` is online (see runner with the label `ubuntu-latest` and `python3` is online (see
[Runners](#runners)). [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 (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, 3. **Add the two secrets** in the repository (or organization) settings,
using the scopes in [credentials.md](credentials.md): using the scopes in [credentials.md](credentials.md):
- `TOKEN_FOR_GITEA` - `TOKEN_FOR_GITEA`
+6 -2
View File
@@ -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 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 | | `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 | | `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 at most one GitHub push mirror ... found N` | configuration | Keep one GitHub push mirror |
| `Expected exactly 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` | | `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` | | `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` | | `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 ## 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 `WARNING: Gitea returned an invalid push mirror list; GitHub metadata was not
synced.` The run succeeds but nothing was synced. Check that the Gitea synced.` The run succeeds but nothing was synced. Check that the Gitea
version supports the push mirror API and re-run. version supports the push mirror API and re-run.
+25 -6
View File
@@ -100,9 +100,8 @@ class TestFindGithubTargets:
[{"remote_address": "https://gitlab.com/Org/other.git"}], [{"remote_address": "https://gitlab.com/Org/other.git"}],
], ],
) )
def test_fails_when_no_github_mirror_exists(self, sync, mirrors): def test_returns_none_when_no_github_mirror_exists(self, sync, mirrors):
with pytest.raises(RuntimeError, match="found 0"): assert sync["find_github_targets"](mirrors) is None
sync["find_github_targets"](mirrors)
def test_fails_when_several_github_mirrors_exist(self, sync): def test_fails_when_several_github_mirrors_exist(self, sync):
mirrors = [ mirrors = [
@@ -249,16 +248,36 @@ class TestMain:
assert all(call[0] == "GET" for call in api.calls) assert all(call[0] == "GET" for call in api.calls)
assert "WARNING" in capsys.readouterr().out 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 self, sync, environment, monkeypatch
): ):
monkeypatch.setenv("GITHUB_CREDENTIALS", "") monkeypatch.setenv("GITHUB_CREDENTIALS", "")
api = FakeApi([]) api = FakeApi([{"remote_address": "git@github.com:Org/repo.git"}])
sync["request_json"] = api sync["request_json"] = api
with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"): with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"):
sync["main"]() 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( def test_stops_before_any_change_when_github_token_cannot_administer(
self, sync, environment self, sync, environment