fix: authenticate private module bootstrap in CI
Dependency Audit / dependency-audit (push) Failing after 9s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 8s

This commit is contained in:
2026-08-02 05:40:22 +02:00
parent ed31409034
commit d9003bf63a
7 changed files with 153 additions and 33 deletions
+4 -2
View File
@@ -1,5 +1,7 @@
name: Dependency Audit name: Dependency Audit
permissions: read-all
on: on:
pull_request: pull_request:
push: push:
@@ -21,13 +23,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: "22" node-version: "22"
- name: Use anonymous HTTPS for public GovOPlaN repositories - name: Use HTTPS for GovOPlaN repositories
run: | 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 "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" 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 - name: Bootstrap GovOPlaN repositories
working-directory: govoplan 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 - name: Install backend dev audit dependencies
working-directory: govoplan working-directory: govoplan
run: | run: |
+4 -2
View File
@@ -1,5 +1,7 @@
name: Module Matrix name: Module Matrix
permissions: read-all
on: on:
workflow_dispatch: workflow_dispatch:
pull_request: pull_request:
@@ -29,13 +31,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: "22" node-version: "22"
- name: Use anonymous HTTPS for public GovOPlaN repositories - name: Use HTTPS for GovOPlaN repositories
run: | 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 "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" 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 - name: Bootstrap GovOPlaN repositories
working-directory: govoplan 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 - name: Install backend release dependencies
working-directory: govoplan working-directory: govoplan
run: | run: |
+4 -2
View File
@@ -1,5 +1,7 @@
name: Release Integration name: Release Integration
permissions: read-all
on: on:
workflow_dispatch: workflow_dispatch:
@@ -16,13 +18,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: "22" node-version: "22"
- name: Use anonymous HTTPS for public GovOPlaN repositories - name: Use HTTPS for GovOPlaN repositories
run: | 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 "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" 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 - name: Bootstrap GovOPlaN repositories
working-directory: govoplan 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 - name: Install backend release integration dependencies
working-directory: govoplan working-directory: govoplan
run: | run: |
+3 -1
View File
@@ -1,5 +1,7 @@
name: Security Audit name: Security Audit
permissions: read-all
on: on:
push: push:
branches: branches:
@@ -30,7 +32,7 @@ jobs:
python-version: "3.12" python-version: "3.12"
- name: Bootstrap GovOPlaN repositories - name: Bootstrap GovOPlaN repositories
working-directory: govoplan 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 - name: Run whole-system security audit
working-directory: govoplan working-directory: govoplan
run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports run: tools/checks/security-audit/run.sh --mode "$SECURITY_AUDIT_MODE" --scope "$SECURITY_AUDIT_SCOPE" --reports-dir audit-reports
+6
View File
@@ -70,6 +70,12 @@ Clone missing repositories listed in `repositories.json`:
./tools/repo/bootstrap-repositories.py ./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: Update generated repository type notes in all READMEs:
```sh ```sh
+69 -19
View File
@@ -94,8 +94,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core", "name": "govoplan-core",
"path": "govoplan-core", "path": "govoplan-core",
"remote": ( "remote": (
"git@git.add-ideas.de:" "git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
"GovOPlaN/govoplan-core.git"
), ),
} }
], ],
@@ -134,6 +133,66 @@ class RepositoryBootstrapTests(unittest.TestCase):
self.assertEqual("/bin/false", environment["GIT_ASKPASS"]) self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"]) 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: def test_main_validates_every_remote_before_cloning(self) -> None:
bootstrap = load_bootstrap_module() bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory: with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
@@ -149,8 +208,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core", "name": "govoplan-core",
"path": "govoplan-core", "path": "govoplan-core",
"remote": ( "remote": (
"git@git.add-ideas.de:" "git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
"GovOPlaN/govoplan-core.git"
), ),
}, },
{ {
@@ -197,9 +255,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"git@git.add-ideas.de:" "git@git.add-ideas.de:"
"add-ideas/addideas-govoplan-website.git" "add-ideas/addideas-govoplan-website.git"
), ),
"bootstrap_transport": ( "bootstrap_transport": (bootstrap.REGISTERED_TRANSPORT),
bootstrap.REGISTERED_TRANSPORT
),
} }
], ],
} }
@@ -240,10 +296,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
{ {
"name": name, "name": name,
"path": name, "path": name,
"remote": ( "remote": (f"git@git.add-ideas.de:GovOPlaN/{name}.git"),
"git@git.add-ideas.de:GovOPlaN/"
f"{name}.git"
),
} }
for name in ("govoplan-core", "govoplan-poll") for name in ("govoplan-core", "govoplan-poll")
], ],
@@ -291,8 +344,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core", "name": "govoplan-core",
"path": "govoplan-core", "path": "govoplan-core",
"remote": ( "remote": (
"git@git.add-ideas.de:" "git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
"GovOPlaN/govoplan-core.git"
), ),
} }
], ],
@@ -333,8 +385,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
"name": "govoplan-core", "name": "govoplan-core",
"path": "govoplan-core", "path": "govoplan-core",
"remote": ( "remote": (
"git@git.add-ideas.de:" "git@git.add-ideas.de:GovOPlaN/govoplan-core.git"
"GovOPlaN/govoplan-core.git"
), ),
} }
], ],
@@ -359,7 +410,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
self.assertEqual(1, status) self.assertEqual(1, status)
runner.assert_not_called() 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, self,
) -> None: ) -> None:
manifest = json.loads( manifest = json.loads(
@@ -372,9 +423,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
} }
self.assertTrue(registered_only) self.assertTrue(registered_only)
for workflow in sorted( for workflow in sorted((META_ROOT / ".gitea" / "workflows").glob("*.yml")):
(META_ROOT / ".gitea" / "workflows").glob("*.yml")
):
contents = workflow.read_text(encoding="utf-8") contents = workflow.read_text(encoding="utf-8")
if ( if (
"bootstrap-repositories.py" not in contents "bootstrap-repositories.py" not in contents
@@ -382,6 +431,7 @@ class RepositoryBootstrapTests(unittest.TestCase):
): ):
continue continue
with self.subTest(workflow=workflow.name): with self.subTest(workflow=workflow.name):
self.assertIn("--reuse-checkout-auth", contents)
for repository in registered_only: for repository in registered_only:
self.assertIn( self.assertIn(
f"--exclude-repo {repository}", f"--exclude-repo {repository}",
+63 -7
View File
@@ -16,15 +16,20 @@ REGISTERED_TRANSPORT = "registered"
PUBLIC_HTTPS_TRANSPORT = "public-https" PUBLIC_HTTPS_TRANSPORT = "public-https"
GITEA_SSH_PREFIX = "git@git.add-ideas.de:" GITEA_SSH_PREFIX = "git@git.add-ideas.de:"
GITEA_HTTPS_PREFIX = "https://git.add-ideas.de/" GITEA_HTTPS_PREFIX = "https://git.add-ideas.de/"
GITEA_REPOSITORY_PATH = re.compile( GITEA_CHECKOUT_AUTH_KEY = "http.https://git.add-ideas.de/.extraheader"
r"(?:GovOPlaN|add-ideas)/[a-z0-9][a-z0-9-]*[.]git" 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: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Clone GovOPlaN repositories listed in repositories.json.") parser = argparse.ArgumentParser(
parser.add_argument("--check", action="store_true", help="Only report missing repositories.") description="Clone GovOPlaN repositories listed in repositories.json."
parser.add_argument("--parent", type=Path, help="Override checkout parent directory.") )
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( parser.add_argument(
"--repo", "--repo",
action="append", 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." "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) return parser.parse_args(argv)
@@ -82,6 +95,45 @@ def clone_remote(remote: str, *, transport: str) -> str:
return GITEA_HTTPS_PREFIX + repository_path 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: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
manifest = json.loads((ROOT / "repositories.json").read_text(encoding="utf-8")) 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) excluded = set(args.exclude_repo)
unknown = (requested | excluded) - set(names) unknown = (requested | excluded) - set(names)
if unknown: if unknown:
raise ValueError(f"unknown registered repositories: {', '.join(sorted(unknown))}") raise ValueError(
f"unknown registered repositories: {', '.join(sorted(unknown))}"
)
overlap = requested & excluded overlap = requested & excluded
if overlap: if overlap:
raise ValueError( raise ValueError(
@@ -119,6 +173,8 @@ def main(argv: list[str] | None = None) -> int:
"GIT_TERMINAL_PROMPT": "0", "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: for entry, repo, remote in missing:
print(f"missing: {entry['name']} -> {repo}") print(f"missing: {entry['name']} -> {repo}")
if not args.check: if not args.check: