From 16b9060d4cf6b0b83446a2f83dce87350673b93a Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 3 Oct 2026 21:19:58 +0800 Subject: [PATCH 1/3] Mark MIL-002, MIL-003 and MIL-004 as Accepted Set the owner/reviewer revision of each milestone to Accepted and deprecate its initial row, as the Product Owner accepted the milestones. No task content changed. Co-Authored-By: Claude Sonnet 5.5 --- docs/milestones/mil-002-onboarding-and-operations.md | 4 ++-- docs/milestones/mil-003-quality-checks.md | 4 ++-- docs/milestones/mil-004-versioned-release.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/milestones/mil-002-onboarding-and-operations.md b/docs/milestones/mil-002-onboarding-and-operations.md index 6ad856a..382deb9 100644 --- a/docs/milestones/mil-002-onboarding-and-operations.md +++ b/docs/milestones/mil-002-onboarding-and-operations.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | +| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | +| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | --- diff --git a/docs/milestones/mil-003-quality-checks.md b/docs/milestones/mil-003-quality-checks.md index e52f440..ae5e4bd 100644 --- a/docs/milestones/mil-003-quality-checks.md +++ b/docs/milestones/mil-003-quality-checks.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | +| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | +| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | --- diff --git a/docs/milestones/mil-004-versioned-release.md b/docs/milestones/mil-004-versioned-release.md index ce22c54..48512e5 100644 --- a/docs/milestones/mil-004-versioned-release.md +++ b/docs/milestones/mil-004-versioned-release.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | -| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | +| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | +| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | --- From 7dcb9c4dfce712572aced281d0dfdfa435283646 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 3 Oct 2026 21:26:49 +0800 Subject: [PATCH 2/3] Add credential preflight, categorized errors and operations guides Sync workflow: every failure is now reported as `ERROR: [category] message` with the categories configuration, credentials (HTTP 401), permissions (HTTP 403) and api (other HTTP errors, unreachable service); HTTP 404 is a configuration error. Before changing anything the workflow checks that the GitHub token can administer the mirror repository and stops with a permissions error otherwise. No secret value is printed. Guides: onboarding (with runner labels, prerequisites and offline runner symptoms), credentials (minimum permissions, preflight, rotation) and troubleshooting (every message mapped to a category and a fix). README capability table updated. MIL-002 records the verification of scoped workflow support: Gitea 1.27.3 runs the workflow in this repository; delivery to other repositories is not yet verified. Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 Task: MIL-002#7 Refs #9 Refs #10 Refs #11 Refs #12 Refs #13 Refs #14 Co-Authored-By: Claude Sonnet 5.5 --- .../scoped_workflows/sync-github-metadata.yml | 98 +++++++++++++++---- README.md | 4 +- .../mil-002-onboarding-and-operations.md | 6 +- guides/credentials.md | 63 ++++++++++++ guides/onboarding.md | 71 ++++++++++++++ guides/troubleshooting.md | 60 ++++++++++++ tests/test_sync_github_metadata.py | 89 +++++++++++++++-- 7 files changed, 361 insertions(+), 30 deletions(-) create mode 100644 guides/credentials.md create mode 100644 guides/onboarding.md create mode 100644 guides/troubleshooting.md diff --git a/.gitea/scoped_workflows/sync-github-metadata.yml b/.gitea/scoped_workflows/sync-github-metadata.yml index 6f9e976..b8dd267 100644 --- a/.gitea/scoped_workflows/sync-github-metadata.yml +++ b/.gitea/scoped_workflows/sync-github-metadata.yml @@ -29,6 +29,25 @@ jobs: import urllib.request + class WorkflowError(RuntimeError): + """A failure with a category. + + configuration: a secret or repository setting is missing or malformed. + credentials: a service rejected the token (HTTP 401). + permissions: the token is valid but not allowed (HTTP 403). + api: any other API or network failure. + """ + + def __init__(self, category, message): + super().__init__(f"[{category}] {message}") + self.category = category + + + def service_name(url): + hostname = urllib.parse.urlsplit(url).hostname + return "GitHub" if hostname == "api.github.com" else "Gitea" + + def request_json(url, method="GET", headers=None, body=None): request = urllib.request.Request( url, @@ -36,37 +55,64 @@ jobs: headers=headers or {}, method=method, ) + service = service_name(url) try: with urllib.request.urlopen(request, timeout=30) as response: content = response.read() return json.loads(content) if content else None except urllib.error.HTTPError as error: - raise RuntimeError( - f"API request failed with HTTP {error.code} ({error.reason})" + detail = ( + f"{service} API request failed with HTTP " + f"{error.code} ({error.reason})" + ) + if error.code == 401: + raise WorkflowError( + "credentials", f"{detail}. The token was rejected." + ) from None + if error.code == 403: + raise WorkflowError( + "permissions", + f"{detail}. The token lacks a required permission.", + ) from None + if error.code == 404: + raise WorkflowError( + "configuration", + f"{detail}. The repository was not found or the " + "token cannot see it.", + ) from None + raise WorkflowError("api", detail) from None + except (urllib.error.URLError, TimeoutError) as error: + raise WorkflowError( + "api", f"{service} API is unreachable ({error})." ) from None def parse_github_token(raw_credentials): raw_credentials = (raw_credentials or "").strip() if not raw_credentials: - raise RuntimeError("CREDENTIALS_FOR_GITHUB is missing or empty.") + raise WorkflowError( + "configuration", "CREDENTIALS_FOR_GITHUB is missing or empty." + ) try: credentials = json.loads(raw_credentials) except json.JSONDecodeError: - raise RuntimeError( - "CREDENTIALS_FOR_GITHUB must contain valid JSON." + raise WorkflowError( + "configuration", + "CREDENTIALS_FOR_GITHUB must contain valid JSON.", ) from None if not isinstance(credentials, dict): - raise RuntimeError( - "CREDENTIALS_FOR_GITHUB must be a JSON object." + raise WorkflowError( + "configuration", + "CREDENTIALS_FOR_GITHUB must be a JSON object.", ) token = credentials.get("GITHUB_PAT") if not isinstance(token, str) or not token.strip(): - raise RuntimeError( - "CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT." + raise WorkflowError( + "configuration", + "CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.", ) return token.strip() @@ -74,7 +120,9 @@ jobs: def parse_gitea_token(raw_credentials): raw_credentials = (raw_credentials or "").strip() if not raw_credentials: - raise RuntimeError("TOKEN_FOR_GITEA is missing or empty.") + raise WorkflowError( + "configuration", "TOKEN_FOR_GITEA is missing or empty." + ) try: credentials = json.loads(raw_credentials) @@ -89,8 +137,9 @@ jobs: token = None if not isinstance(token, str) or not token.strip(): - raise RuntimeError( - "TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN." + raise WorkflowError( + "configuration", + "TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.", ) return token.strip() @@ -98,7 +147,10 @@ jobs: def split_repository(full_name): owner, separator, repo = (full_name or "").partition("/") if not separator or not owner or not repo: - raise RuntimeError("Could not determine the Gitea source repository.") + raise WorkflowError( + "configuration", + "Could not determine the Gitea source repository.", + ) return owner, repo @@ -117,16 +169,18 @@ jobs: mirror_path = mirror_path.removesuffix(".git").strip("/") path_parts = mirror_path.split("/") if len(path_parts) != 2 or not all(path_parts): - raise RuntimeError( + raise WorkflowError( + "configuration", "Could not determine the GitHub owner and repository " - "from a configured push mirror." + "from a configured push mirror.", ) targets.append(tuple(path_parts)) if len(targets) != 1: - raise RuntimeError( + raise WorkflowError( + "configuration", "Expected exactly one GitHub push mirror for this repository; " - f"found {len(targets)}." + f"found {len(targets)}.", ) return targets[0] @@ -169,6 +223,16 @@ jobs: "Content-Type": "application/json", } + github_repository = request_json(github_api_url, headers=github_headers) + permissions = (github_repository or {}).get("permissions") + if isinstance(permissions, dict) and not permissions.get("admin"): + raise WorkflowError( + "permissions", + f"The GitHub token cannot administer {github_owner}/{github_repo}; " + "editing the description and topics needs administration access.", + ) + print("Preflight passed: Gitea and GitHub accepted the credentials.") + request_json( github_api_url, method="PATCH", diff --git a/README.md b/README.md index ef008ea..19c795e 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ Only the first row is implemented. The rest is planned and tracked in the [Proje | 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 | Partial: secrets are checked for format only | -| Onboard a repository, manage runners, diagnose failures | Planned (MIL-002) | +| Configure and validate workflow credentials | Implemented for the sync workflow: preflight checks both tokens before any change ([guide](guides/credentials.md)) | +| Onboard a repository, manage runners, diagnose failures | Guides written ([onboarding](guides/onboarding.md), [troubleshooting](guides/troubleshooting.md)); 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) | diff --git a/docs/milestones/mil-002-onboarding-and-operations.md b/docs/milestones/mil-002-onboarding-and-operations.md index 382deb9..e6ad095 100644 --- a/docs/milestones/mil-002-onboarding-and-operations.md +++ b/docs/milestones/mil-002-onboarding-and-operations.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] | -| 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 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | +| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Recorded verification results for task 2 | pending | --- @@ -63,7 +63,7 @@ a credential validation step in the workflow, and categorized failure messages. | # | Task | Summary | Needs its own Use Case/User Story? | Reference | | --- | --- | --- | --- | --- | | 1 | Write brief use cases for the P1 candidates | Turn the DevOps use cases in the Project Plan appendix into use case documents under `docs/uc-NNN/`, after a use case diagram and user stories exist. | No | | -| 2 | Verify scoped workflow support on the instance | The Business Case lists this as an unverified assumption. Check the Gitea version and how a scoped workflow reaches a repository, then record the result. | No | | +| 2 | Verify scoped workflow support on the instance | The Business Case lists this as an unverified assumption. Check the Gitea version and how a scoped workflow reaches a repository, then record the result. Result: Gitea 1.27.3 runs the scoped sync workflow in this repository (run 16); delivery to other repositories is not yet verified and is recorded in `guides/onboarding.md`. | No | | | 3 | Write the repository onboarding steps | Describe how to consume the workflow (scoped or copied), which secrets to set and how to test the first run. | No | | | 4 | Document least-privilege tokens | Determine the minimum Gitea token scope and GitHub fine-grained token permission that the sync needs, and document them. | No | | | 5 | Add a credential preflight step | Before the sync, check that both tokens authenticate and have the needed access, and report the result without printing any secret. | No | | diff --git a/guides/credentials.md b/guides/credentials.md new file mode 100644 index 0000000..037fc29 --- /dev/null +++ b/guides/credentials.md @@ -0,0 +1,63 @@ +# 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. Exactly one GitHub push mirror is configured. +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.md). 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/guides/onboarding.md b/guides/onboarding.md new file mode 100644 index 0000000..4c7bd2e --- /dev/null +++ b/guides/onboarding.md @@ -0,0 +1,71 @@ +# 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](../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 exactly 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. +3. **Add the two secrets** in the repository (or organization) settings, + using the scopes in [credentials.md](credentials.md): + - `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.md). + +## 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/guides/troubleshooting.md b/guides/troubleshooting.md new file mode 100644 index 0000000..aa49370 --- /dev/null +++ b/guides/troubleshooting.md @@ -0,0 +1,60 @@ +# 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 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 | +| `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 + +`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.md#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. diff --git a/tests/test_sync_github_metadata.py b/tests/test_sync_github_metadata.py index 37c55bf..14b7f37 100644 --- a/tests/test_sync_github_metadata.py +++ b/tests/test_sync_github_metadata.py @@ -119,23 +119,68 @@ class TestFindGithubTargets: class TestRequestJson: - def test_maps_http_error_without_leaking_the_response_body(self, sync, monkeypatch): + @staticmethod + def raise_http_error(sync, monkeypatch, code, reason): def fake_urlopen(request, timeout): raise urllib.error.HTTPError( - request.full_url, 403, "Forbidden", {}, io.BytesIO(b"secret detail") + request.full_url, code, reason, {}, io.BytesIO(b"secret detail") ) monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen) + + @pytest.mark.parametrize( + ("code", "reason", "category"), + [ + (401, "Unauthorized", "credentials"), + (403, "Forbidden", "permissions"), + (404, "Not Found", "configuration"), + (500, "Server Error", "api"), + ], + ) + def test_categorizes_http_errors(self, sync, monkeypatch, code, reason, category): + self.raise_http_error(sync, monkeypatch, code, reason) + with pytest.raises(sync["WorkflowError"]) as caught: + sync["request_json"]("https://example.test/x") + assert caught.value.category == category + assert str(caught.value).startswith(f"[{category}] Gitea API request failed") + assert f"HTTP {code} ({reason})" in str(caught.value) + + def test_does_not_leak_the_response_body(self, sync, monkeypatch): + self.raise_http_error(sync, monkeypatch, 403, "Forbidden") with pytest.raises(RuntimeError) as caught: sync["request_json"]("https://example.test/x") - assert str(caught.value) == "API request failed with HTTP 403 (Forbidden)" + assert "secret detail" not in str(caught.value) + + def test_names_github_for_github_urls(self, sync, monkeypatch): + self.raise_http_error(sync, monkeypatch, 401, "Unauthorized") + with pytest.raises(RuntimeError, match="GitHub API request failed"): + sync["request_json"]("https://api.github.com/repos/Org/repo") + + def test_reports_unreachable_service_as_api_failure(self, sync, monkeypatch): + def fake_urlopen(request, timeout): + raise urllib.error.URLError("name resolution failed") + + monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen) + with pytest.raises(sync["WorkflowError"]) as caught: + sync["request_json"]("https://example.test/x") + assert caught.value.category == "api" + assert "unreachable" in str(caught.value) + + +class TestWorkflowError: + def test_configuration_errors_carry_their_category(self, sync): + with pytest.raises(sync["WorkflowError"]) as caught: + sync["parse_github_token"]("") + assert caught.value.category == "configuration" + assert str(caught.value).startswith("[configuration] ") class FakeApi: """Records calls and answers like the Gitea and GitHub REST APIs.""" - def __init__(self, mirrors, source=None): + def __init__(self, mirrors, source=None, github=None): self.mirrors = mirrors + self.github = github if github is not None else {"permissions": {"admin": True}} self.source = source or {"description": "Desc", "topics": ["a", "b"]} self.calls = [] @@ -145,6 +190,8 @@ class FakeApi: return self.mirrors if url.startswith("https://git.example.test"): return self.source + if method == "GET" and url.startswith("https://api.github.com"): + return self.github return None @@ -169,13 +216,14 @@ class TestMain: assert requests == [ ("GET", "https://git.example.test/api/v1/repos/TirSystem/repo"), ("GET", "https://git.example.test/api/v1/repos/TirSystem/repo/push_mirrors"), + ("GET", "https://api.github.com/repos/Org/repo"), ("PATCH", "https://api.github.com/repos/Org/repo"), ("PUT", "https://api.github.com/repos/Org/repo/topics"), ] assert api.calls[0][2]["Authorization"] == "token gitea-token" assert api.calls[2][2]["Authorization"] == "Bearer gh-token" - assert api.calls[2][3] == {"description": "Desc"} - assert api.calls[3][3] == {"names": ["a", "b"]} + assert api.calls[3][3] == {"description": "Desc"} + assert api.calls[4][3] == {"names": ["a", "b"]} assert "Synced description and topics to Org/repo." in capsys.readouterr().out def test_sends_empty_values_when_gitea_has_none(self, sync, environment): @@ -187,8 +235,8 @@ class TestMain: sync["main"]() - assert api.calls[2][3] == {"description": ""} - assert api.calls[3][3] == {"names": []} + assert api.calls[3][3] == {"description": ""} + assert api.calls[4][3] == {"names": []} def test_skips_with_a_warning_when_mirror_list_is_invalid( self, sync, environment, capsys @@ -211,3 +259,28 @@ class TestMain: with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"): sync["main"]() assert api.calls == [] + + def test_stops_before_any_change_when_github_token_cannot_administer( + self, sync, environment + ): + api = FakeApi( + [{"remote_address": "git@github.com:Org/repo.git"}], + github={"permissions": {"admin": False, "push": True}}, + ) + sync["request_json"] = api + + with pytest.raises(sync["WorkflowError"]) as caught: + sync["main"]() + + assert caught.value.category == "permissions" + assert all(call[0] == "GET" for call in api.calls) + + def test_does_not_print_any_secret(self, sync, environment, capsys): + api = FakeApi([{"remote_address": "git@github.com:Org/repo.git"}]) + sync["request_json"] = api + + sync["main"]() + + output = capsys.readouterr() + assert "gh-token" not in output.out + output.err + assert "gitea-token" not in output.out + output.err From a1561e2c6ce0bea4c68fb5410b976fad8d767e82 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 3 Oct 2026 21:26:53 +0800 Subject: [PATCH 3/3] Resolve pending commit link in MIL-002 version history Co-Authored-By: Claude Sonnet 5.5 --- docs/milestones/mil-002-onboarding-and-operations.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/milestones/mil-002-onboarding-and-operations.md b/docs/milestones/mil-002-onboarding-and-operations.md index e6ad095..e7f3398 100644 --- a/docs/milestones/mil-002-onboarding-and-operations.md +++ b/docs/milestones/mil-002-onboarding-and-operations.md @@ -10,7 +10,7 @@ | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | | 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] | -| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Recorded verification results for task 2 | pending | +| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Recorded verification results for task 2 | [7dcb9c4] | --- @@ -74,3 +74,4 @@ a credential validation step in the workflow, and categorized failure messages. [BC-001]: ../business-case.md [4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d +[7dcb9c4]: https://git.tirsystem.com/TirSystem/github-action/commit/7dcb9c4dfce712572aced281d0dfdfa435283646