diff --git a/.gitea/workflows/dependency-audit.yml b/.gitea/workflows/dependency-audit.yml index aba2aa5..8faa407 100644 --- a/.gitea/workflows/dependency-audit.yml +++ b/.gitea/workflows/dependency-audit.yml @@ -1,5 +1,7 @@ name: Dependency Audit +permissions: read-all + on: pull_request: push: @@ -21,13 +23,13 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "22" - - name: Use anonymous HTTPS for public GovOPlaN repositories + - name: Use HTTPS for GovOPlaN repositories run: | git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan" git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan" - name: Bootstrap GovOPlaN repositories working-directory: govoplan - run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website + run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website - name: Install backend dev audit dependencies working-directory: govoplan run: | diff --git a/.gitea/workflows/module-matrix.yml b/.gitea/workflows/module-matrix.yml index 7b44e1e..5e313e3 100644 --- a/.gitea/workflows/module-matrix.yml +++ b/.gitea/workflows/module-matrix.yml @@ -1,5 +1,7 @@ name: Module Matrix +permissions: read-all + on: workflow_dispatch: pull_request: @@ -29,13 +31,13 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "22" - - name: Use anonymous HTTPS for public GovOPlaN repositories + - name: Use HTTPS for GovOPlaN repositories run: | git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan" git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan" - name: Bootstrap GovOPlaN repositories working-directory: govoplan - run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website + run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website - name: Install backend release dependencies working-directory: govoplan run: | diff --git a/.gitea/workflows/release-integration.yml b/.gitea/workflows/release-integration.yml index 427a510..f42279a 100644 --- a/.gitea/workflows/release-integration.yml +++ b/.gitea/workflows/release-integration.yml @@ -1,5 +1,7 @@ name: Release Integration +permissions: read-all + on: workflow_dispatch: @@ -16,13 +18,13 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "22" - - name: Use anonymous HTTPS for public GovOPlaN repositories + - name: Use HTTPS for GovOPlaN repositories run: | git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "git@git.add-ideas.de:GovOPlaN/govoplan" git config --global --add url."https://git.add-ideas.de/GovOPlaN/govoplan".insteadOf "ssh://git@git.add-ideas.de/GovOPlaN/govoplan" - name: Bootstrap GovOPlaN repositories working-directory: govoplan - run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website + run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website - name: Install backend release integration dependencies working-directory: govoplan run: | diff --git a/.gitea/workflows/security-audit.yml b/.gitea/workflows/security-audit.yml index 1717576..0c01f25 100644 --- a/.gitea/workflows/security-audit.yml +++ b/.gitea/workflows/security-audit.yml @@ -1,5 +1,7 @@ name: Security Audit +permissions: read-all + on: push: branches: @@ -30,7 +32,7 @@ jobs: python-version: "3.12" - name: Bootstrap GovOPlaN repositories working-directory: govoplan - run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website + run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --reuse-checkout-auth --exclude-repo addideas-govoplan-website - name: Run whole-system security audit working-directory: govoplan run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports diff --git a/README.md b/README.md index be25adc..a4fa62a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ Clone missing repositories listed in `repositories.json`: ./tools/repo/bootstrap-repositories.py ``` +Gitea Actions jobs bootstrap the registered repositories over HTTPS and reuse +only the checkout job's short-lived authentication header. If registered +modules are private, allow the meta repository read access under +`GovOPlaN -> Settings -> Actions -> General -> Cross-Repository Access`; no +long-lived personal token is stored by the workflow or bootstrap tool. + Update generated repository type notes in all READMEs: ```sh diff --git a/tests/test_repository_bootstrap.py b/tests/test_repository_bootstrap.py index aeb95d5..8773b76 100644 --- a/tests/test_repository_bootstrap.py +++ b/tests/test_repository_bootstrap.py @@ -94,8 +94,7 @@ class RepositoryBootstrapTests(unittest.TestCase): "name": "govoplan-core", "path": "govoplan-core", "remote": ( - "git@git.add-ideas.de:" - "GovOPlaN/govoplan-core.git" + "git@git.add-ideas.de:GovOPlaN/govoplan-core.git" ), } ], @@ -134,6 +133,66 @@ class RepositoryBootstrapTests(unittest.TestCase): self.assertEqual("/bin/false", environment["GIT_ASKPASS"]) self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"]) + def test_checkout_auth_is_forwarded_without_entering_clone_arguments(self) -> None: + bootstrap = load_bootstrap_module() + environment = { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "url.https://example.test/.insteadOf", + "GIT_CONFIG_VALUE_0": "ssh://example.test/", + } + auth_header = "AUTHORIZATION: basic c2hvcnQtbGl2ZWQtam9iLXRva2Vu" + + with patch.object( + bootstrap.subprocess, + "run", + return_value=bootstrap.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=auth_header + "\n", + ), + ) as runner: + bootstrap._add_checkout_auth(environment, root=Path("/workspace/meta")) + + runner.assert_called_once_with( + [ + "git", + "-C", + "/workspace/meta", + "config", + "--local", + "--get", + bootstrap.GITEA_CHECKOUT_AUTH_KEY, + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual("2", environment["GIT_CONFIG_COUNT"]) + self.assertEqual( + bootstrap.GITEA_CHECKOUT_AUTH_KEY, + environment["GIT_CONFIG_KEY_1"], + ) + self.assertEqual(auth_header, environment["GIT_CONFIG_VALUE_1"]) + + def test_checkout_auth_fails_closed_when_checkout_did_not_persist_it(self) -> None: + bootstrap = load_bootstrap_module() + + with ( + patch.object( + bootstrap.subprocess, + "run", + return_value=bootstrap.subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + ), + ), + self.assertRaisesRegex( + ValueError, "checkout authentication is unavailable" + ), + ): + bootstrap._add_checkout_auth({}, root=Path("/workspace/meta")) + def test_main_validates_every_remote_before_cloning(self) -> None: bootstrap = load_bootstrap_module() with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory: @@ -149,8 +208,7 @@ class RepositoryBootstrapTests(unittest.TestCase): "name": "govoplan-core", "path": "govoplan-core", "remote": ( - "git@git.add-ideas.de:" - "GovOPlaN/govoplan-core.git" + "git@git.add-ideas.de:GovOPlaN/govoplan-core.git" ), }, { @@ -197,9 +255,7 @@ class RepositoryBootstrapTests(unittest.TestCase): "git@git.add-ideas.de:" "add-ideas/addideas-govoplan-website.git" ), - "bootstrap_transport": ( - bootstrap.REGISTERED_TRANSPORT - ), + "bootstrap_transport": (bootstrap.REGISTERED_TRANSPORT), } ], } @@ -240,10 +296,7 @@ class RepositoryBootstrapTests(unittest.TestCase): { "name": name, "path": name, - "remote": ( - "git@git.add-ideas.de:GovOPlaN/" - f"{name}.git" - ), + "remote": (f"git@git.add-ideas.de:GovOPlaN/{name}.git"), } for name in ("govoplan-core", "govoplan-poll") ], @@ -291,8 +344,7 @@ class RepositoryBootstrapTests(unittest.TestCase): "name": "govoplan-core", "path": "govoplan-core", "remote": ( - "git@git.add-ideas.de:" - "GovOPlaN/govoplan-core.git" + "git@git.add-ideas.de:GovOPlaN/govoplan-core.git" ), } ], @@ -333,8 +385,7 @@ class RepositoryBootstrapTests(unittest.TestCase): "name": "govoplan-core", "path": "govoplan-core", "remote": ( - "git@git.add-ideas.de:" - "GovOPlaN/govoplan-core.git" + "git@git.add-ideas.de:GovOPlaN/govoplan-core.git" ), } ], @@ -359,7 +410,7 @@ class RepositoryBootstrapTests(unittest.TestCase): self.assertEqual(1, status) runner.assert_not_called() - def test_anonymous_ci_bootstrap_excludes_registered_transport_repositories( + def test_ci_bootstrap_reuses_checkout_auth_and_excludes_registered_transport_repositories( self, ) -> None: manifest = json.loads( @@ -372,9 +423,7 @@ class RepositoryBootstrapTests(unittest.TestCase): } self.assertTrue(registered_only) - for workflow in sorted( - (META_ROOT / ".gitea" / "workflows").glob("*.yml") - ): + for workflow in sorted((META_ROOT / ".gitea" / "workflows").glob("*.yml")): contents = workflow.read_text(encoding="utf-8") if ( "bootstrap-repositories.py" not in contents @@ -382,6 +431,7 @@ class RepositoryBootstrapTests(unittest.TestCase): ): continue with self.subTest(workflow=workflow.name): + self.assertIn("--reuse-checkout-auth", contents) for repository in registered_only: self.assertIn( f"--exclude-repo {repository}", diff --git a/tools/repo/bootstrap-repositories.py b/tools/repo/bootstrap-repositories.py index a1e8967..71fa2e1 100644 --- a/tools/repo/bootstrap-repositories.py +++ b/tools/repo/bootstrap-repositories.py @@ -16,15 +16,20 @@ REGISTERED_TRANSPORT = "registered" PUBLIC_HTTPS_TRANSPORT = "public-https" GITEA_SSH_PREFIX = "git@git.add-ideas.de:" GITEA_HTTPS_PREFIX = "https://git.add-ideas.de/" -GITEA_REPOSITORY_PATH = re.compile( - r"(?:GovOPlaN|add-ideas)/[a-z0-9][a-z0-9-]*[.]git" -) +GITEA_CHECKOUT_AUTH_KEY = "http.https://git.add-ideas.de/.extraheader" +GITEA_REPOSITORY_PATH = re.compile(r"(?:GovOPlaN|add-ideas)/[a-z0-9][a-z0-9-]*[.]git") def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Clone GovOPlaN repositories listed in repositories.json.") - parser.add_argument("--check", action="store_true", help="Only report missing repositories.") - parser.add_argument("--parent", type=Path, help="Override checkout parent directory.") + parser = argparse.ArgumentParser( + description="Clone GovOPlaN repositories listed in repositories.json." + ) + parser.add_argument( + "--check", action="store_true", help="Only report missing repositories." + ) + parser.add_argument( + "--parent", type=Path, help="Override checkout parent directory." + ) parser.add_argument( "--repo", action="append", @@ -48,6 +53,14 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "git.add-ideas.de SSH remotes to anonymous HTTPS." ), ) + parser.add_argument( + "--reuse-checkout-auth", + action="store_true", + help=( + "Reuse the checkout repository's short-lived Gitea HTTP auth " + "header for child clones without printing or persisting it." + ), + ) return parser.parse_args(argv) @@ -82,6 +95,45 @@ def clone_remote(remote: str, *, transport: str) -> str: return GITEA_HTTPS_PREFIX + repository_path +def _add_checkout_auth(environment: dict[str, str], *, root: Path) -> None: + result = subprocess.run( + [ + "git", + "-C", + str(root), + "config", + "--local", + "--get", + GITEA_CHECKOUT_AUTH_KEY, + ], + check=False, + capture_output=True, + text=True, + ) + auth_header = result.stdout.strip() + if result.returncode != 0 or not auth_header: + raise ValueError( + "checkout authentication is unavailable; ensure actions/checkout " + "persists the GITEA_TOKEN credentials" + ) + if re.fullmatch( + r"authorization: basic [A-Za-z0-9+/=]+", + auth_header, + flags=re.IGNORECASE, + ) is None: + raise ValueError("checkout authentication has an unsupported format") + + try: + config_count = int(environment.get("GIT_CONFIG_COUNT", "0")) + except ValueError as exc: + raise ValueError("GIT_CONFIG_COUNT must be an integer") from exc + if config_count < 0: + raise ValueError("GIT_CONFIG_COUNT cannot be negative") + environment[f"GIT_CONFIG_KEY_{config_count}"] = GITEA_CHECKOUT_AUTH_KEY + environment[f"GIT_CONFIG_VALUE_{config_count}"] = auth_header + environment["GIT_CONFIG_COUNT"] = str(config_count + 1) + + def main(argv: list[str] | None = None) -> int: args = parse_args(argv) manifest = json.loads((ROOT / "repositories.json").read_text(encoding="utf-8")) @@ -95,7 +147,9 @@ def main(argv: list[str] | None = None) -> int: excluded = set(args.exclude_repo) unknown = (requested | excluded) - set(names) if unknown: - raise ValueError(f"unknown registered repositories: {', '.join(sorted(unknown))}") + raise ValueError( + f"unknown registered repositories: {', '.join(sorted(unknown))}" + ) overlap = requested & excluded if overlap: raise ValueError( @@ -119,6 +173,8 @@ def main(argv: list[str] | None = None) -> int: "GIT_TERMINAL_PROMPT": "0", } ) + if args.reuse_checkout_auth and missing and not args.check: + _add_checkout_auth(environment, root=ROOT) for entry, repo, remote in missing: print(f"missing: {entry['name']} -> {repo}") if not args.check: