Compare commits

...

2 Commits

Author SHA1 Message Date
3fc17701df ci: fix action repository access
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m45s
Security Audit / security-audit (push) Failing after 3m33s
2026-07-23 00:10:29 +02:00
9788bdde0c ci: harden repository bootstrap transport 2026-07-23 00:09:43 +02:00
9 changed files with 499 additions and 56 deletions

View File

@@ -21,23 +21,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: "22" node-version: "22"
- name: Configure SSH for release dependencies - name: Use anonymous HTTPS for public GovOPlaN repositories
env:
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
run: | run: |
mkdir -p ~/.ssh git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "git@git.add-ideas.de:add-ideas/govoplan"
chmod 700 ~/.ssh git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "ssh://git@git.add-ideas.de/add-ideas/govoplan"
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
exit 1
fi
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Bootstrap GovOPlaN repositories - name: Bootstrap GovOPlaN repositories
working-directory: govoplan working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --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: |

View File

@@ -17,23 +17,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: "22" node-version: "22"
- name: Configure SSH for release dependencies - name: Use anonymous HTTPS for public GovOPlaN repositories
env:
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
run: | run: |
mkdir -p ~/.ssh git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "git@git.add-ideas.de:add-ideas/govoplan"
chmod 700 ~/.ssh git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "ssh://git@git.add-ideas.de/add-ideas/govoplan"
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
exit 1
fi
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Bootstrap GovOPlaN repositories - name: Bootstrap GovOPlaN repositories
working-directory: govoplan working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --exclude-repo addideas-govoplan-website
- name: Install backend release dependencies - name: Install backend release dependencies
working-directory: govoplan working-directory: govoplan
run: | run: |

View File

@@ -16,23 +16,13 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with: with:
node-version: "22" node-version: "22"
- name: Configure SSH for release dependencies - name: Use anonymous HTTPS for public GovOPlaN repositories
env:
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
run: | run: |
mkdir -p ~/.ssh git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "git@git.add-ideas.de:add-ideas/govoplan"
chmod 700 ~/.ssh git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "ssh://git@git.add-ideas.de/add-ideas/govoplan"
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
exit 1
fi
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Bootstrap GovOPlaN repositories - name: Bootstrap GovOPlaN repositories
working-directory: govoplan working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https --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: |

View File

@@ -1,7 +1,6 @@
name: Security Audit name: Security Audit
on: on:
pull_request:
push: push:
branches: branches:
- main - main
@@ -21,6 +20,10 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
path: govoplan path: govoplan
- name: Use anonymous HTTPS for public GovOPlaN repositories
run: |
git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "git@git.add-ideas.de:add-ideas/govoplan"
git config --global --add url."https://git.add-ideas.de/add-ideas/govoplan".insteadOf "ssh://git@git.add-ideas.de/add-ideas/govoplan"
- name: Configure SSH for repository bootstrap - name: Configure SSH for repository bootstrap
env: env:
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }} GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
@@ -37,8 +40,8 @@ jobs:
chmod 600 ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts
- name: Bootstrap GovOPlaN repositories - name: Bootstrap GovOPlaN repositories
working-directory: govoplan working-directory: govoplan
run: python tools/repo/bootstrap-repositories.py --parent .. run: python tools/repo/bootstrap-repositories.py --parent .. --transport public-https
- name: Run 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
- name: Upload audit reports - name: Upload audit reports

View File

@@ -162,7 +162,12 @@ clusters that cross module ownership or make behavior harder to change safely.
The regular `Security Audit` workflow reuses the fingerprinted toolbox image The regular `Security Audit` workflow reuses the fingerprinted toolbox image
when the Docker daemon is persistent, which is the normal case for the when the Docker daemon is persistent, which is the normal case for the
self-hosted Gitea runner using the host Docker socket. The separate self-hosted Gitea runner using the host Docker socket. Trusted push, schedule,
and manual runs scan all registered repositories; authenticated SSH is used
only for the private website repository. Pull-request audit runs stay disabled
while the audit runner exposes its host Docker socket: PR-controlled audit code
must run on a disposable or rootless runner without host-socket access. The
separate
`Security Audit Toolbox Update` workflow runs weekly with `Security Audit Toolbox Update` workflow runs weekly with
`SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the `SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the
allowed tool version ranges into a refreshed local image. allowed tool version ranges into a refreshed local image.

View File

@@ -64,7 +64,7 @@
{"name": "govoplan-templates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-templates.git", "path": "govoplan-templates"}, {"name": "govoplan-templates", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-templates.git", "path": "govoplan-templates"},
{"name": "govoplan-tenancy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-tenancy.git", "path": "govoplan-tenancy"}, {"name": "govoplan-tenancy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-tenancy.git", "path": "govoplan-tenancy"},
{"name": "govoplan-transparency", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-transparency.git", "path": "govoplan-transparency"}, {"name": "govoplan-transparency", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-transparency.git", "path": "govoplan-transparency"},
{"name": "addideas-govoplan-website", "category": "website", "subtype": "public-site", "remote": "git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git", "path": "addideas-govoplan-website"}, {"name": "addideas-govoplan-website", "category": "website", "subtype": "public-site", "remote": "git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git", "path": "addideas-govoplan-website", "bootstrap_transport": "registered"},
{"name": "govoplan-workflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-workflow.git", "path": "govoplan-workflow"}, {"name": "govoplan-workflow", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-workflow.git", "path": "govoplan-workflow"},
{"name": "govoplan-xoev", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xoev.git", "path": "govoplan-xoev"}, {"name": "govoplan-xoev", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xoev.git", "path": "govoplan-xoev"},
{"name": "govoplan-xrechnung", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xrechnung.git", "path": "govoplan-xrechnung"}, {"name": "govoplan-xrechnung", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-xrechnung.git", "path": "govoplan-xrechnung"},

View File

@@ -0,0 +1,364 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
META_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = META_ROOT / "tools" / "repo" / "bootstrap-repositories.py"
def load_bootstrap_module():
spec = importlib.util.spec_from_file_location("bootstrap_repositories", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class RepositoryBootstrapTests(unittest.TestCase):
def test_public_https_transport_rewrites_registered_gitea_remotes(self) -> None:
bootstrap = load_bootstrap_module()
self.assertEqual(
"https://git.add-ideas.de/add-ideas/govoplan-core.git",
bootstrap.clone_remote(
"git@git.add-ideas.de:add-ideas/govoplan-core.git",
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
),
)
self.assertEqual(
"https://git.add-ideas.de/add-ideas/govoplan-core.git",
bootstrap.clone_remote(
"ssh://git@git.add-ideas.de/add-ideas/govoplan-core.git",
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
),
)
self.assertEqual(
"https://git.add-ideas.de/add-ideas/govoplan-core.git",
bootstrap.clone_remote(
"https://git.add-ideas.de/add-ideas/govoplan-core.git",
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
),
)
def test_registered_transport_preserves_the_manifest_remote(self) -> None:
bootstrap = load_bootstrap_module()
remote = "git@example.test:private/repository.git"
self.assertEqual(
remote,
bootstrap.clone_remote(
remote,
transport=bootstrap.REGISTERED_TRANSPORT,
),
)
def test_public_https_transport_fails_closed_for_other_hosts(self) -> None:
bootstrap = load_bootstrap_module()
unsafe_remotes = (
"git@example.test:add-ideas/govoplan-core.git",
"https://token@git.add-ideas.de/add-ideas/govoplan-core.git",
"https://git.add-ideas.de:443/add-ideas/govoplan-core.git",
"https://git.add-ideas.de/add-ideas/../govoplan-core.git",
"https://git.add-ideas.de/add-ideas/govoplan-core.git?ref=main",
"ssh://root@git.add-ideas.de/add-ideas/govoplan-core.git",
)
for remote in unsafe_remotes:
with self.subTest(remote=remote), self.assertRaises(ValueError):
bootstrap.clone_remote(
remote,
transport=bootstrap.PUBLIC_HTTPS_TRANSPORT,
)
def test_main_clones_missing_repositories_over_public_https(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
root = Path(directory)
parent = root / "checkouts"
parent.mkdir()
root.joinpath("repositories.json").write_text(
json.dumps(
{
"default_parent": str(parent),
"repositories": [
{
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"add-ideas/govoplan-core.git"
),
}
],
}
),
encoding="utf-8",
)
with (
patch.object(bootstrap, "ROOT", root),
patch.object(bootstrap.subprocess, "run") as runner,
):
status = bootstrap.main(
[
"--parent",
str(parent),
"--transport",
bootstrap.PUBLIC_HTTPS_TRANSPORT,
]
)
self.assertEqual(0, status)
runner.assert_called_once_with(
[
"git",
"-c",
"credential.helper=",
"clone",
"--",
"https://git.add-ideas.de/add-ideas/govoplan-core.git",
str(parent / "govoplan-core"),
],
check=True,
env=runner.call_args.kwargs["env"],
)
environment = runner.call_args.kwargs["env"]
self.assertEqual("/bin/false", environment["GIT_ASKPASS"])
self.assertEqual("0", environment["GIT_TERMINAL_PROMPT"])
def test_main_validates_every_remote_before_cloning(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
root = Path(directory)
parent = root / "checkouts"
parent.mkdir()
root.joinpath("repositories.json").write_text(
json.dumps(
{
"default_parent": str(parent),
"repositories": [
{
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"add-ideas/govoplan-core.git"
),
},
{
"name": "unsafe",
"path": "unsafe",
"remote": "git@example.test:private/unsafe.git",
},
],
}
),
encoding="utf-8",
)
with (
patch.object(bootstrap, "ROOT", root),
patch.object(bootstrap.subprocess, "run") as runner,
self.assertRaises(ValueError),
):
bootstrap.main(
[
"--parent",
str(parent),
"--transport",
bootstrap.PUBLIC_HTTPS_TRANSPORT,
]
)
runner.assert_not_called()
def test_main_preserves_an_explicit_private_repository_transport(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
root = Path(directory)
parent = root / "checkouts"
parent.mkdir()
root.joinpath("repositories.json").write_text(
json.dumps(
{
"default_parent": str(parent),
"repositories": [
{
"name": "website",
"path": "website",
"remote": (
"git@git.add-ideas.de:"
"add-ideas/addideas-govoplan-website.git"
),
"bootstrap_transport": (
bootstrap.REGISTERED_TRANSPORT
),
}
],
}
),
encoding="utf-8",
)
with (
patch.object(bootstrap, "ROOT", root),
patch.object(bootstrap.subprocess, "run") as runner,
):
status = bootstrap.main(
[
"--parent",
str(parent),
"--transport",
bootstrap.PUBLIC_HTTPS_TRANSPORT,
]
)
self.assertEqual(0, status)
command = runner.call_args.args[0]
self.assertEqual(
"git@git.add-ideas.de:add-ideas/addideas-govoplan-website.git",
command[-2],
)
def test_main_limits_bootstrap_to_selected_repositories(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
root = Path(directory)
parent = root / "checkouts"
parent.mkdir()
root.joinpath("repositories.json").write_text(
json.dumps(
{
"default_parent": str(parent),
"repositories": [
{
"name": name,
"path": name,
"remote": (
"git@git.add-ideas.de:add-ideas/"
f"{name}.git"
),
}
for name in ("govoplan-core", "govoplan-poll")
],
}
),
encoding="utf-8",
)
for repository_filter in (
["--repo", "govoplan-core"],
["--exclude-repo", "govoplan-poll"],
):
with (
self.subTest(repository_filter=repository_filter),
patch.object(bootstrap, "ROOT", root),
patch.object(bootstrap.subprocess, "run") as runner,
):
status = bootstrap.main(
[
"--parent",
str(parent),
"--transport",
bootstrap.PUBLIC_HTTPS_TRANSPORT,
*repository_filter,
]
)
self.assertEqual(0, status)
runner.assert_called_once()
self.assertEqual(
"https://git.add-ideas.de/add-ideas/govoplan-core.git",
runner.call_args.args[0][-2],
)
def test_main_rejects_unknown_or_conflicting_repository_filters(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
root = Path(directory)
parent = root / "checkouts"
parent.mkdir()
root.joinpath("repositories.json").write_text(
json.dumps(
{
"default_parent": str(parent),
"repositories": [
{
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"add-ideas/govoplan-core.git"
),
}
],
}
),
encoding="utf-8",
)
with (
patch.object(bootstrap, "ROOT", root),
patch.object(bootstrap.subprocess, "run") as runner,
):
with self.assertRaisesRegex(ValueError, "unknown registered"):
bootstrap.main(["--repo", "govoplan-missing"])
with self.assertRaisesRegex(ValueError, "selected and excluded"):
bootstrap.main(
[
"--repo",
"govoplan-core",
"--exclude-repo",
"govoplan-core",
]
)
runner.assert_not_called()
def test_check_reports_missing_without_cloning(self) -> None:
bootstrap = load_bootstrap_module()
with tempfile.TemporaryDirectory(prefix="govoplan-bootstrap-") as directory:
root = Path(directory)
parent = root / "checkouts"
parent.mkdir()
root.joinpath("repositories.json").write_text(
json.dumps(
{
"default_parent": str(parent),
"repositories": [
{
"name": "govoplan-core",
"path": "govoplan-core",
"remote": (
"git@git.add-ideas.de:"
"add-ideas/govoplan-core.git"
),
}
],
}
),
encoding="utf-8",
)
with (
patch.object(bootstrap, "ROOT", root),
patch.object(bootstrap.subprocess, "run") as runner,
):
status = bootstrap.main(
[
"--check",
"--parent",
str(parent),
"--transport",
bootstrap.PUBLIC_HTTPS_TRANSPORT,
]
)
self.assertEqual(1, status)
runner.assert_not_called()
if __name__ == "__main__":
unittest.main()

0
tools/checks/security-audit/run.sh Normal file → Executable file
View File

View File

@@ -3,35 +3,136 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
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"add-ideas/[a-z0-9][a-z0-9-]*[.]git")
def parse_args() -> 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(description="Clone GovOPlaN repositories listed in repositories.json.")
parser.add_argument("--check", action="store_true", help="Only report missing repositories.") 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("--parent", type=Path, help="Override checkout parent directory.")
return parser.parse_args() parser.add_argument(
"--repo",
action="append",
default=[],
metavar="NAME",
help="Clone only this registered repository; repeat for more than one.",
)
parser.add_argument(
"--exclude-repo",
action="append",
default=[],
metavar="NAME",
help="Exclude this registered repository; repeat for more than one.",
)
parser.add_argument(
"--transport",
choices=(REGISTERED_TRANSPORT, PUBLIC_HTTPS_TRANSPORT),
default=REGISTERED_TRANSPORT,
help=(
"Clone registered remotes unchanged, or convert public "
"git.add-ideas.de SSH remotes to anonymous HTTPS."
),
)
return parser.parse_args(argv)
def main() -> int: def clone_remote(remote: str, *, transport: str) -> str:
args = parse_args() if transport == REGISTERED_TRANSPORT:
manifest = json.loads((ROOT / "repositories.json").read_text()) return remote
if transport != PUBLIC_HTTPS_TRANSPORT:
raise ValueError(f"unsupported repository transport: {transport}")
if remote.startswith(GITEA_SSH_PREFIX):
repository_path = remote.removeprefix(GITEA_SSH_PREFIX)
else:
parsed = urlsplit(remote)
if (
parsed.scheme not in {"https", "ssh"}
or parsed.hostname != "git.add-ideas.de"
or parsed.port is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
"public HTTPS bootstrap only accepts registered "
"git.add-ideas.de remotes"
)
if parsed.scheme == "https" and parsed.username is not None:
raise ValueError("public HTTPS bootstrap does not accept credentials")
if parsed.scheme == "ssh" and parsed.username != "git":
raise ValueError("registered Gitea SSH remotes must use the git account")
repository_path = parsed.path.removeprefix("/")
if GITEA_REPOSITORY_PATH.fullmatch(repository_path) is None:
raise ValueError("registered Gitea repository path is malformed")
return GITEA_HTTPS_PREFIX + repository_path
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
manifest = json.loads((ROOT / "repositories.json").read_text(encoding="utf-8"))
parent = args.parent or Path(manifest["default_parent"]) parent = args.parent or Path(manifest["default_parent"])
missing: list[dict[str, str]] = [] missing: list[tuple[dict[str, str], Path, str]] = []
repositories = manifest["repositories"]
names = [entry["name"] for entry in repositories]
if len(names) != len(set(names)):
raise ValueError("repository registry contains duplicate names")
requested = set(args.repo)
excluded = set(args.exclude_repo)
unknown = (requested | excluded) - set(names)
if unknown:
raise ValueError(f"unknown registered repositories: {', '.join(sorted(unknown))}")
overlap = requested & excluded
if overlap:
raise ValueError(
f"repositories cannot be selected and excluded: {', '.join(sorted(overlap))}"
)
for entry in manifest["repositories"]: for entry in repositories:
if (requested and entry["name"] not in requested) or entry["name"] in excluded:
continue
repo = parent / entry["path"] repo = parent / entry["path"]
if repo.exists(): if repo.exists():
continue continue
missing.append(entry) transport = entry.get("bootstrap_transport", args.transport)
remote = clone_remote(entry["remote"], transport=transport)
missing.append((entry, repo, remote))
environment = os.environ.copy()
environment.update(
{
"GIT_ASKPASS": "/bin/false",
"GIT_TERMINAL_PROMPT": "0",
}
)
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:
subprocess.run(["git", "clone", entry["remote"], str(repo)], check=True) subprocess.run(
[
"git",
"-c",
"credential.helper=",
"clone",
"--",
remote,
str(repo),
],
check=True,
env=environment,
)
if args.check: if args.check:
return 1 if missing else 0 return 1 if missing else 0