quoll-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. quoll/__init__.py +0 -0
  2. quoll/cd/__init__.py +1 -0
  3. quoll/cd/config.py +75 -0
  4. quoll/ci/__init__.py +0 -0
  5. quoll/ci/config.py +213 -0
  6. quoll/ci/custom.py +32 -0
  7. quoll/ci/detect.py +139 -0
  8. quoll/ci/steps.py +85 -0
  9. quoll/ci/tools.py +104 -0
  10. quoll/commands/__init__.py +0 -0
  11. quoll/commands/_common.py +145 -0
  12. quoll/commands/_workspace.py +83 -0
  13. quoll/commands/add.py +104 -0
  14. quoll/commands/add_secret.py +75 -0
  15. quoll/commands/cd.py +214 -0
  16. quoll/commands/ci.py +521 -0
  17. quoll/commands/docker.py +244 -0
  18. quoll/commands/k8s.py +211 -0
  19. quoll/commands/new.py +337 -0
  20. quoll/commands/remove.py +79 -0
  21. quoll/config/__init__.py +0 -0
  22. quoll/config/quoll_toml.py +196 -0
  23. quoll/config/settings.py +22 -0
  24. quoll/config/store.py +39 -0
  25. quoll/console.py +3 -0
  26. quoll/docker/__init__.py +1 -0
  27. quoll/docker/config.py +64 -0
  28. quoll/docker/detect.py +41 -0
  29. quoll/docker/render.py +162 -0
  30. quoll/files.py +64 -0
  31. quoll/k8s/__init__.py +0 -0
  32. quoll/k8s/chart/templates/_helpers.tpl +38 -0
  33. quoll/k8s/chart/templates/deployment.yaml +93 -0
  34. quoll/k8s/chart/templates/hpa.yaml +24 -0
  35. quoll/k8s/chart/templates/ingress.yaml +35 -0
  36. quoll/k8s/chart/templates/pdb.yaml +16 -0
  37. quoll/k8s/chart/templates/service.yaml +18 -0
  38. quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
  39. quoll/k8s/ci.py +39 -0
  40. quoll/k8s/config.py +97 -0
  41. quoll/k8s/deploy.py +22 -0
  42. quoll/k8s/detect.py +32 -0
  43. quoll/k8s/render.py +94 -0
  44. quoll/languages/__init__.py +121 -0
  45. quoll/languages/base.py +350 -0
  46. quoll/languages/cpp/Dockerfile.j2 +37 -0
  47. quoll/languages/cpp/__init__.py +17 -0
  48. quoll/languages/cpp/ci.py +192 -0
  49. quoll/languages/cpp/detect.py +84 -0
  50. quoll/languages/cpp/docker.py +47 -0
  51. quoll/languages/dotnet/Dockerfile.j2 +21 -0
  52. quoll/languages/dotnet/__init__.py +17 -0
  53. quoll/languages/dotnet/ci.py +88 -0
  54. quoll/languages/dotnet/detect.py +53 -0
  55. quoll/languages/dotnet/docker.py +47 -0
  56. quoll/languages/go/Dockerfile.j2 +25 -0
  57. quoll/languages/go/__init__.py +17 -0
  58. quoll/languages/go/ci.py +126 -0
  59. quoll/languages/go/detect.py +30 -0
  60. quoll/languages/go/docker.py +40 -0
  61. quoll/languages/java/Dockerfile.j2 +28 -0
  62. quoll/languages/java/__init__.py +20 -0
  63. quoll/languages/java/ci.py +163 -0
  64. quoll/languages/java/detect.py +77 -0
  65. quoll/languages/java/docker.py +45 -0
  66. quoll/languages/java/frameworks/__init__.py +1 -0
  67. quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
  68. quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
  69. quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
  70. quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
  71. quoll/languages/java/frameworks/spring_boot/services.py +24 -0
  72. quoll/languages/node/Dockerfile.j2 +98 -0
  73. quoll/languages/node/__init__.py +29 -0
  74. quoll/languages/node/ci.py +200 -0
  75. quoll/languages/node/detect.py +164 -0
  76. quoll/languages/node/docker.py +141 -0
  77. quoll/languages/node/frameworks/__init__.py +1 -0
  78. quoll/languages/node/frameworks/angular/__init__.py +18 -0
  79. quoll/languages/node/frameworks/angular/ci.py +34 -0
  80. quoll/languages/node/frameworks/angular/detect.py +20 -0
  81. quoll/languages/node/frameworks/angular/docker.py +31 -0
  82. quoll/languages/node/frameworks/angular/starter.py +74 -0
  83. quoll/languages/node/frameworks/astro/__init__.py +10 -0
  84. quoll/languages/node/frameworks/astro/ci.py +17 -0
  85. quoll/languages/node/frameworks/astro/detect.py +14 -0
  86. quoll/languages/node/frameworks/astro/docker.py +17 -0
  87. quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
  88. quoll/languages/node/frameworks/nestjs/detect.py +9 -0
  89. quoll/languages/node/frameworks/nestjs/docker.py +10 -0
  90. quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
  91. quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
  92. quoll/languages/node/frameworks/nextjs/ci.py +29 -0
  93. quoll/languages/node/frameworks/nextjs/detect.py +13 -0
  94. quoll/languages/node/frameworks/nextjs/docker.py +41 -0
  95. quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
  96. quoll/languages/node/frameworks/nuxt/ci.py +17 -0
  97. quoll/languages/node/frameworks/nuxt/detect.py +17 -0
  98. quoll/languages/node/frameworks/nuxt/docker.py +24 -0
  99. quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
  100. quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
  101. quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
  102. quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
  103. quoll/languages/node/starter.py +42 -0
  104. quoll/languages/node/templates/express/README.md +11 -0
  105. quoll/languages/node/templates/express/env.example +2 -0
  106. quoll/languages/node/templates/express/eslint.config.js +15 -0
  107. quoll/languages/node/templates/express/gitignore +4 -0
  108. quoll/languages/node/templates/express/nvmrc +1 -0
  109. quoll/languages/node/templates/express/package.json +16 -0
  110. quoll/languages/node/templates/express/prettierignore +6 -0
  111. quoll/languages/node/templates/express/src/app.ts +27 -0
  112. quoll/languages/node/templates/express/src/server.ts +11 -0
  113. quoll/languages/node/templates/express/test/app.test.ts +29 -0
  114. quoll/languages/node/templates/express/tsconfig.build.json +8 -0
  115. quoll/languages/node/templates/express/tsconfig.json +14 -0
  116. quoll/languages/node/templates/express/vitest.config.ts +12 -0
  117. quoll/languages/php/Dockerfile.j2 +40 -0
  118. quoll/languages/php/__init__.py +19 -0
  119. quoll/languages/php/ci.py +147 -0
  120. quoll/languages/php/detect.py +75 -0
  121. quoll/languages/php/docker.py +59 -0
  122. quoll/languages/php/frameworks/__init__.py +1 -0
  123. quoll/languages/php/frameworks/laravel/__init__.py +17 -0
  124. quoll/languages/php/frameworks/laravel/ci.py +24 -0
  125. quoll/languages/php/frameworks/laravel/detect.py +13 -0
  126. quoll/languages/php/frameworks/laravel/docker.py +29 -0
  127. quoll/languages/php/frameworks/laravel/services.py +25 -0
  128. quoll/languages/php/frameworks/symfony/__init__.py +15 -0
  129. quoll/languages/php/frameworks/symfony/ci.py +22 -0
  130. quoll/languages/php/frameworks/symfony/detect.py +17 -0
  131. quoll/languages/php/frameworks/symfony/docker.py +25 -0
  132. quoll/languages/python/Dockerfile.j2 +61 -0
  133. quoll/languages/python/__init__.py +19 -0
  134. quoll/languages/python/ci.py +157 -0
  135. quoll/languages/python/detect.py +175 -0
  136. quoll/languages/python/docker.py +80 -0
  137. quoll/languages/python/frameworks/__init__.py +1 -0
  138. quoll/languages/python/frameworks/django/__init__.py +16 -0
  139. quoll/languages/python/frameworks/django/ci.py +43 -0
  140. quoll/languages/python/frameworks/django/detect.py +17 -0
  141. quoll/languages/python/frameworks/django/docker.py +20 -0
  142. quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
  143. quoll/languages/python/frameworks/fastapi/detect.py +9 -0
  144. quoll/languages/python/frameworks/fastapi/docker.py +21 -0
  145. quoll/languages/python/frameworks/flask/__init__.py +7 -0
  146. quoll/languages/python/frameworks/flask/detect.py +9 -0
  147. quoll/languages/python/frameworks/flask/docker.py +22 -0
  148. quoll/languages/ruby/Dockerfile.j2 +45 -0
  149. quoll/languages/ruby/__init__.py +19 -0
  150. quoll/languages/ruby/ci.py +110 -0
  151. quoll/languages/ruby/detect.py +72 -0
  152. quoll/languages/ruby/docker.py +63 -0
  153. quoll/languages/ruby/frameworks/__init__.py +1 -0
  154. quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
  155. quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
  156. quoll/languages/ruby/frameworks/rails/ci.py +26 -0
  157. quoll/languages/ruby/frameworks/rails/detect.py +15 -0
  158. quoll/languages/ruby/frameworks/rails/docker.py +62 -0
  159. quoll/languages/ruby/frameworks/rails/services.py +10 -0
  160. quoll/languages/rust/Dockerfile.j2 +23 -0
  161. quoll/languages/rust/__init__.py +17 -0
  162. quoll/languages/rust/ci.py +125 -0
  163. quoll/languages/rust/detect.py +46 -0
  164. quoll/languages/rust/docker.py +38 -0
  165. quoll/main.py +50 -0
  166. quoll/platforms/__init__.py +62 -0
  167. quoll/platforms/base.py +103 -0
  168. quoll/platforms/github/__init__.py +4 -0
  169. quoll/platforms/github/actions.py +78 -0
  170. quoll/platforms/github/api.py +124 -0
  171. quoll/platforms/github/cd.py +265 -0
  172. quoll/platforms/github/ci.py +497 -0
  173. quoll/platforms/github/dependabot.yml.j2 +49 -0
  174. quoll/platforms/github/platform.py +52 -0
  175. quoll/platforms/github/workflow.py +74 -0
  176. quoll/platforms/gitlab/__init__.py +4 -0
  177. quoll/platforms/gitlab/api.py +158 -0
  178. quoll/platforms/gitlab/cd.py +226 -0
  179. quoll/platforms/gitlab/ci.py +400 -0
  180. quoll/platforms/gitlab/pipeline.py +136 -0
  181. quoll/platforms/gitlab/platform.py +54 -0
  182. quoll/project.py +60 -0
  183. quoll/services.py +117 -0
  184. quoll/starters.py +117 -0
  185. quoll/workspace.py +232 -0
  186. quoll/yaml_dump.py +137 -0
  187. quoll_cli-0.2.0.dist-info/METADATA +641 -0
  188. quoll_cli-0.2.0.dist-info/RECORD +191 -0
  189. quoll_cli-0.2.0.dist-info/WHEEL +4 -0
  190. quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
  191. quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/main.py ADDED
@@ -0,0 +1,50 @@
1
+ from importlib.metadata import version
2
+
3
+ import typer
4
+
5
+ from quoll.commands import add, cd, ci, docker, k8s, remove
6
+ from quoll.commands.add_secret import app as add_secret_app
7
+ from quoll.commands.new import new
8
+ from quoll.console import console
9
+
10
+ app = typer.Typer(
11
+ name="quoll",
12
+ help="Production-grade CI, Dockerfiles, Helm charts and delivery for any repository, kept in sync with quoll.toml.",
13
+ no_args_is_help=True,
14
+ )
15
+
16
+
17
+ def _version(value: bool) -> None:
18
+ if value:
19
+ console.print(f"quoll {version('quoll-cli')}")
20
+ raise typer.Exit()
21
+
22
+
23
+ @app.callback()
24
+ def main(
25
+ _: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show quoll's version."),
26
+ ) -> None:
27
+ """Production-grade CI, Dockerfiles, Helm charts and delivery for any repository, kept in sync with quoll.toml."""
28
+
29
+ app.command("new")(new)
30
+ app.add_typer(add.app, name="add")
31
+ app.add_typer(remove.app, name="remove")
32
+ app.add_typer(ci.app, name="ci")
33
+ app.add_typer(docker.app, name="docker")
34
+ app.add_typer(cd.app, name="cd")
35
+ app.add_typer(k8s.app, name="k8s")
36
+ app.add_typer(add_secret_app, name="add-secret")
37
+ # Kept for scripts written against 0.1.x.
38
+ app.command("init-ci", hidden=True, help="Alias of `quoll ci init`.")(ci.init)
39
+
40
+
41
+ @app.command("init", hidden=True, context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
42
+ @app.command("create-project", hidden=True, context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
43
+ def _replaced() -> None:
44
+ """The scaffolding of 0.1.x."""
45
+ console.print("[red]Error:[/red] replaced by [bold]quoll new[/bold], e.g. quoll new shop --front angular "
46
+ "--back express (see quoll new --help).")
47
+ raise typer.Exit(1)
48
+
49
+ if __name__ == "__main__":
50
+ app()
@@ -0,0 +1,62 @@
1
+ """Code hosting platforms: where the repository lives and the CI/CD pipelines run.
2
+
3
+ Each platform is a sub-package exposing `platform`, a `quoll.platforms.base.Platform`. The project's platform is
4
+ `platform` in the [project] section of quoll.toml, detected from the git remote.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ import re
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from quoll.platforms.base import Platform, PlatformError, Remote
15
+
16
+ __all__ = ["NAMES", "Platform", "PlatformError", "Remote", "detect", "get", "hosting", "parse_remote", "remote"]
17
+
18
+ NAMES = ("github", "gitlab")
19
+
20
+
21
+ def get(name: str) -> Platform:
22
+ if name not in NAMES:
23
+ raise KeyError(f"unknown platform '{name}' (expected one of: {', '.join(NAMES)})")
24
+ return importlib.import_module(f"quoll.platforms.{name}").platform
25
+
26
+
27
+ def parse_remote(url: str) -> Optional[Remote]:
28
+ """Host and repository path of a git remote URL (https, ssh or scp-like)."""
29
+ m = re.match(r"^(?:[a-z+]+://)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.+?)(?:\.git)?/?$", url.strip())
30
+ if not m or "/" not in m.group(2):
31
+ return None
32
+ return Remote(host=m.group(1).lower(), path=m.group(2))
33
+
34
+
35
+ def remote(project_dir: Path) -> Optional[Remote]:
36
+ """Where `origin` points to."""
37
+ try:
38
+ result = subprocess.run(
39
+ ["git", "remote", "get-url", "origin"], cwd=project_dir, capture_output=True, text=True, timeout=5
40
+ )
41
+ except (OSError, subprocess.SubprocessError):
42
+ return None
43
+ return parse_remote(result.stdout) if result.returncode == 0 else None
44
+
45
+
46
+ def hosting(origin: Remote) -> Optional[str]:
47
+ """The platform serving `origin`, when its host tells (github.com, gitlab.com, gitlab.<company>...)."""
48
+ if origin.host == "github.com":
49
+ return "github"
50
+ if "gitlab" in origin.host:
51
+ return "gitlab"
52
+ return None
53
+
54
+
55
+ def detect(project_dir: Path, origin: Optional[Remote] = None) -> str:
56
+ """The platform hosting the project: from the remote's host, else from the pipelines already there."""
57
+ hosted = hosting(origin) if origin else None
58
+ if hosted:
59
+ return hosted
60
+ if (project_dir / ".gitlab-ci.yml").exists() and not (project_dir / ".github" / "workflows").is_dir():
61
+ return "gitlab"
62
+ return "github"
@@ -0,0 +1,103 @@
1
+ """The contract every code hosting platform implements.
2
+
3
+ A platform knows where its pipelines live and how to render them from quoll.toml (CI from [ci], delivery from
4
+ [cd]), and talks to its API to create repositories, store CI secrets and protect branches.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union
12
+
13
+ if TYPE_CHECKING:
14
+ from quoll.cd.config import CDConfig
15
+ from quoll.ci.config import CIConfig
16
+ from quoll.files import PlannedFile
17
+ from quoll.workspace import Delivery, Repo
18
+
19
+
20
+ class PlatformError(Exception):
21
+ pass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Remote:
26
+ """Where a repository is hosted, e.g. ("github.com", "acme/api") or ("gitlab.com", "acme/backend/api")."""
27
+
28
+ host: str
29
+ # owner/repo on GitHub; group/[subgroups/]project on GitLab.
30
+ path: str
31
+
32
+ @property
33
+ def namespace(self) -> str:
34
+ return self.path.rsplit("/", 1)[0] if "/" in self.path else ""
35
+
36
+ @property
37
+ def name(self) -> str:
38
+ return self.path.rsplit("/", 1)[-1]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Repo:
43
+ path: str
44
+ clone_url: str
45
+ web_url: str
46
+
47
+
48
+ class PlatformAPI(ABC):
49
+ @abstractmethod
50
+ def create_repo(self, name: str, private: bool = True) -> Repo:
51
+ """Create an empty repository in the configured organization/group, or the user's namespace."""
52
+
53
+ @abstractmethod
54
+ def set_secrets(self, repo: str, secrets: Dict[str, str]) -> None:
55
+ """Store CI secrets (GitHub) / masked CI/CD variables (GitLab) on the repository at path `repo`."""
56
+
57
+ def ensure_schedule(self, repo: str, branch: str, cron: str) -> bool:
58
+ """Create or update the schedule running the security scans. False when the pipeline file already holds
59
+ it (GitHub's `on: schedule`)."""
60
+ return False
61
+
62
+ @abstractmethod
63
+ def protect_branch(
64
+ self, repo: str, branch: str, required_checks: Sequence[str] = (), required_reviews: int = 0
65
+ ) -> List[str]:
66
+ """Forbid direct pushes and require a green pipeline (and reviews) to merge. Returns warnings."""
67
+
68
+
69
+ class Platform(ABC):
70
+ name: str
71
+ label: str
72
+ # What the platform calls the values `quoll` stores for the CI, e.g. "secrets".
73
+ secrets_label: str
74
+ # Registries `quoll cd` can push to; the first is the default.
75
+ registries: Tuple[str, ...]
76
+ # Status checks branch protection requires, when the platform can't require "the pipeline" as a whole.
77
+ required_checks: Tuple[str, ...] = ()
78
+ # Scheduled pipelines are configured through the API rather than in the pipeline file.
79
+ schedules_via_api: bool = False
80
+
81
+ @abstractmethod
82
+ def ci_files(self, project_dir: Path, cfg: Union[CIConfig, Repo]) -> List[PlannedFile]:
83
+ """The CI pipeline of a project or a workspace (`Repo`), then files only created when missing."""
84
+
85
+ @abstractmethod
86
+ def cd_files(self, project_dir: Path, cfg: Union[CDConfig, List[Delivery]]) -> List[PlannedFile]:
87
+ """The delivery pipeline of a project, or of the workspace members' deliveries."""
88
+
89
+ @abstractmethod
90
+ def default_image(self, remote: Optional[Remote], registry: str, fallback_name: str, member: str = "") -> str:
91
+ """Image name `quoll cd` suggests for `registry` (for the workspace `member`, if any)."""
92
+
93
+ @abstractmethod
94
+ def registry_secrets(self, registry: str) -> List[str]:
95
+ """Secrets the user must set for `registry` (none for the platform's own registry)."""
96
+
97
+ @abstractmethod
98
+ def merge_gate(self) -> str:
99
+ """How to require the pipeline before merging, shown after `quoll ci init`."""
100
+
101
+ @abstractmethod
102
+ def api(self, host: Optional[str] = None) -> PlatformAPI:
103
+ """Client for the platform's API (on `host` for self-managed instances)."""
@@ -0,0 +1,4 @@
1
+ """GitHub: GitHub Actions workflows, Dependabot, GHCR, and the REST API."""
2
+ from quoll.platforms.github.platform import GitHub
3
+
4
+ platform = GitHub()
@@ -0,0 +1,78 @@
1
+ """GitHub Actions used by the generated workflows."""
2
+
3
+ # Referenced by version here; workflows pin them to the commit SHA below (supply-chain hardening: a tag can be
4
+ # moved, a commit can't), with the version as a comment. Dependabot (github-actions ecosystem) proposes updates.
5
+ ACTIONS = {
6
+ "checkout": "actions/checkout@v7",
7
+ "setup-python": "actions/setup-python@v7",
8
+ "setup-node": "actions/setup-node@v7",
9
+ "setup-go": "actions/setup-go@v7",
10
+ "setup-java": "actions/setup-java@v6",
11
+ "setup-gradle": "gradle/actions/setup-gradle@v6",
12
+ "setup-dotnet": "actions/setup-dotnet@v6",
13
+ "setup-php": "shivammathur/setup-php@v2",
14
+ "setup-ruby": "ruby/setup-ruby@v1",
15
+ "setup-rust": "actions-rust-lang/setup-rust-toolchain@v2",
16
+ "install-tool": "taiki-e/install-action@v2",
17
+ "cache": "actions/cache@v6",
18
+ # No floating major tag upstream since v8.
19
+ "setup-uv": "astral-sh/setup-uv@v10.3.3",
20
+ "setup-pnpm": "pnpm/action-setup@v6",
21
+ "setup-bun": "oven-sh/setup-bun@v2",
22
+ "golangci-lint": "golangci/golangci-lint-action@v9",
23
+ "codecov": "codecov/codecov-action@v7",
24
+ "setup-buildx": "docker/setup-buildx-action@v4",
25
+ "build-push": "docker/build-push-action@v7",
26
+ "setup-qemu": "docker/setup-qemu-action@v4",
27
+ "metadata": "docker/metadata-action@v6",
28
+ "login": "docker/login-action@v4",
29
+ # No major-version tag upstream: dependabot bumps it.
30
+ "trivy": "aquasecurity/trivy-action@v0.36.0",
31
+ "sonar": "SonarSource/sonarqube-scan-action@v8",
32
+ "codeql-init": "github/codeql-action/init@v4",
33
+ "codeql-analyze": "github/codeql-action/analyze@v4",
34
+ "junit-report": "mikepenz/action-junit-report@v6",
35
+ "upload-artifact": "actions/upload-artifact@v7",
36
+ "attest": "actions/attest@v4",
37
+ "setup-helm": "azure/setup-helm@v5",
38
+ }
39
+
40
+ # Commit of each version above. Refresh with:
41
+ # gh api repos/<owner>/<repo>/commits/<version> --jq .sha
42
+ PINS = {
43
+ "SonarSource/sonarqube-scan-action@v8": "59202af334b4ba7b5db01b701b8e79f8b3bef0f4",
44
+ "actions-rust-lang/setup-rust-toolchain@v2": "a188e68d0fd2bd36ad51dccb2b07ba6e75105f30",
45
+ "actions/cache@v6": "55cc8345863c7cc4c66a329aec7e433d2d1c52a9",
46
+ "actions/checkout@v7": "3d3c42e5aac5ba805825da76410c181273ba90b1",
47
+ "actions/setup-dotnet@v6": "0f4a354dd5547a28c70b7853dbd95d5b67886e73",
48
+ "actions/setup-go@v7": "b7ad1dad31e06c5925ef5d2fc7ad053ef454303e",
49
+ "actions/setup-java@v6": "b843ebf9b913146b38da2eaa7aeddf76074ae1ad",
50
+ "actions/setup-node@v7": "949feb2413d6458794dcd2491c4babbbce0c15c1",
51
+ "actions/setup-python@v7": "6d4fd22b20465ccc0f7354476588e8df7c9ca0e0",
52
+ "aquasecurity/trivy-action@v0.36.0": "ed142fd0673e97e23eac54620cfb913e5ce36c25",
53
+ "astral-sh/setup-uv@v10.3.3": "68559e99cc807c1eb7e9238fd43a15bba460eec2",
54
+ "codecov/codecov-action@v7": "303a32d7a59b442fa8d48b6a1cc6825c09c847a5",
55
+ "docker/build-push-action@v7": "c3c9e263c25d99ce0380d002d59b67737d91b0dc",
56
+ "docker/login-action@v4": "8aa5831fb8d48d8f76594c7ddde61ae58019f550",
57
+ "docker/metadata-action@v6": "dc802804100637a589fabce1cb79ff13a1411302",
58
+ "docker/setup-buildx-action@v4": "f87e5991a6d7451dcb8d9637bfbc97413f497069",
59
+ "docker/setup-qemu-action@v4": "99012661954931238ded8c8b007157a8430204e1",
60
+ "github/codeql-action/analyze@v4": "33d7b85da1a04171e211befe2c739303b9a4c066",
61
+ "github/codeql-action/init@v4": "33d7b85da1a04171e211befe2c739303b9a4c066",
62
+ "actions/upload-artifact@v7": "cf430e030ddbb5b0abf93d22962f4752f3646cd9",
63
+ "azure/setup-helm@v5": "2f5e7bc67f1f9972ab16657bb795f36d8f04de0e",
64
+ "actions/attest@v4": "1e69f48acb82d1966a394da916b4c1698aa569d6",
65
+ "mikepenz/action-junit-report@v6": "a16be3a69023e9bd7e748ede768b3282ea7b30ce",
66
+ "golangci/golangci-lint-action@v9": "ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a",
67
+ "gradle/actions/setup-gradle@v6": "8da46b539cd8fe6ab27ca75128919a18d757604c",
68
+ "oven-sh/setup-bun@v2": "0c5077e51419868618aeaa5fe8019c62421857d6",
69
+ "pnpm/action-setup@v6": "73e46992e05b19abd31f2f3c20e0e7370d762115",
70
+ "ruby/setup-ruby@v1": "b3aaceaef7e0ee231b499e7b5f49c03fc1d32199",
71
+ "shivammathur/setup-php@v2": "eb7c497e18156a6bbabfef1d3a82760b9eda3962",
72
+ "taiki-e/install-action@v2": "cfdb7ed4ccc7c7dce37759eae89dc5674b469b39",
73
+ }
74
+
75
+
76
+ def gha_cache_scope(project: str) -> str:
77
+ """BuildKit's GitHub Actions cache, one scope per project: a monorepo's images don't evict each other's layers."""
78
+ return f"type=gha,scope={project or 'ci'}"
@@ -0,0 +1,124 @@
1
+ """GitHub REST API: repositories, Actions secrets, branch protection."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from typing import Dict, List, Sequence, Tuple
6
+
7
+ import httpx
8
+
9
+ from quoll.config.settings import settings
10
+ from quoll.platforms.base import PlatformAPI, PlatformError, Repo
11
+
12
+ GITHUB_API = "https://api.github.com"
13
+
14
+ _ERROR_HINTS = {
15
+ 401: "The token is invalid or expired (QUOLL_GITHUB_TOKEN, `gh auth login`, or ~/.config/quoll/config.toml).",
16
+ 403: "The token lacks permissions: it needs the 'repo' scope.",
17
+ 422: "The repository may already exist on GitHub.",
18
+ }
19
+
20
+
21
+ class GitHubError(PlatformError):
22
+ pass
23
+
24
+
25
+ def github_client() -> httpx.Client:
26
+ return httpx.Client(
27
+ base_url=GITHUB_API,
28
+ headers={
29
+ "Authorization": f"Bearer {settings.github_token}",
30
+ "Accept": "application/vnd.github+json",
31
+ "X-GitHub-Api-Version": "2022-11-28",
32
+ },
33
+ timeout=30,
34
+ )
35
+
36
+
37
+ class GitHubAPI(PlatformAPI):
38
+ def create_repo(self, name: str, private: bool = True) -> Repo:
39
+ with github_client() as client:
40
+ endpoint = f"/orgs/{settings.github_org}/repos" if settings.github_org else "/user/repos"
41
+ response = client.post(
42
+ endpoint,
43
+ json={"name": name, "private": private, "auto_init": False, "description": "Scaffolded by quoll"},
44
+ )
45
+ _raise_for_status(response, "create the repository")
46
+ data = response.json()
47
+ return Repo(path=data.get("full_name", name), clone_url=data["clone_url"], web_url=data.get("html_url", ""))
48
+
49
+ def set_secrets(self, repo: str, secrets: Dict[str, str]) -> None:
50
+ """Encrypt and upload Actions secrets."""
51
+ repo = self._full_path(repo)
52
+ with github_client() as client:
53
+ key_id, public_key = _repo_public_key(client, repo)
54
+ for name, value in secrets.items():
55
+ response = client.put(
56
+ f"/repos/{repo}/actions/secrets/{name}",
57
+ json={"encrypted_value": _encrypt_secret(public_key, value), "key_id": key_id},
58
+ )
59
+ _raise_for_status(response, f"save the secret {name}")
60
+
61
+ def protect_branch(
62
+ self, repo: str, branch: str, required_checks: Sequence[str] = (), required_reviews: int = 0
63
+ ) -> List[str]:
64
+ repo = self._full_path(repo)
65
+ # Changes go through a pull request; approvals are on top (GitHub doesn't let authors approve their own:
66
+ # one required approval would lock a project with a single developer out of its default branch).
67
+ reviews = {"required_approving_review_count": required_reviews, "dismiss_stale_reviews": True}
68
+ with github_client() as client:
69
+ response = client.put(
70
+ f"/repos/{repo}/branches/{branch}/protection",
71
+ json={
72
+ "required_status_checks": {"strict": True, "contexts": list(required_checks)},
73
+ "enforce_admins": True,
74
+ "required_pull_request_reviews": reviews,
75
+ "restrictions": None,
76
+ "required_linear_history": True,
77
+ "allow_force_pushes": False,
78
+ "allow_deletions": False,
79
+ },
80
+ )
81
+ if response.is_error:
82
+ raise GitHubError(f"GitHub {response.status_code} while protecting the branch {branch}.")
83
+ return []
84
+
85
+ def _full_path(self, repo: str) -> str:
86
+ """owner/repo; a bare name belongs to the configured organization, or to the authenticated user."""
87
+ if "/" in repo:
88
+ return repo
89
+ return f"{settings.github_org or _authenticated_user()}/{repo}"
90
+
91
+
92
+ def _authenticated_user() -> str:
93
+ with github_client() as client:
94
+ response = client.get("/user")
95
+ if response.is_error:
96
+ raise GitHubError(f"GitHub {response.status_code} while reading the authenticated user.")
97
+ return response.json()["login"]
98
+
99
+
100
+ def _repo_public_key(client: httpx.Client, repo: str) -> Tuple[str, str]:
101
+ response = client.get(f"/repos/{repo}/actions/secrets/public-key")
102
+ _raise_for_status(response, "read the secrets' public key")
103
+ data = response.json()
104
+ return data["key_id"], data["key"]
105
+
106
+
107
+ def _encrypt_secret(public_key_b64: str, secret_value: str) -> str:
108
+ """Encrypt a secret using the repo's public key (libsodium sealed box)."""
109
+ try:
110
+ from nacl import public
111
+ except ImportError as exc:
112
+ raise RuntimeError("PyNaCl is required for secret encryption: pip install PyNaCl") from exc
113
+
114
+ sealed_box = public.SealedBox(public.PublicKey(base64.b64decode(public_key_b64)))
115
+ return base64.b64encode(sealed_box.encrypt(secret_value.encode("utf-8"))).decode("utf-8")
116
+
117
+
118
+ def _raise_for_status(response: httpx.Response, action: str) -> None:
119
+ if response.is_error:
120
+ hint = _ERROR_HINTS.get(response.status_code, "")
121
+ msg = f"GitHub {response.status_code} while trying to {action}."
122
+ if hint:
123
+ msg += f" {hint}"
124
+ raise GitHubError(msg)