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.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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"
|
quoll/platforms/base.py
ADDED
|
@@ -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,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)
|