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
@@ -0,0 +1,497 @@
1
+ """Turn a CIConfig into GitHub Actions files."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Sequence, Union
7
+
8
+ from jinja2 import Environment, FileSystemLoader
9
+
10
+ from quoll.ci import custom
11
+ from quoll.ci.config import SECURITY_JOBS, CIConfig
12
+ from quoll.ci.steps import Item, step
13
+ from quoll.ci.tools import (
14
+ GITLEAKS_IMAGE,
15
+ SBOM_FILE,
16
+ SEMGREP_IMAGE,
17
+ dependency_checks,
18
+ image_checks,
19
+ sast,
20
+ secret_scan,
21
+ )
22
+ from quoll.files import PlannedFile, header, planned
23
+ from quoll.k8s.ci import HELM_VERSION, chart_checks
24
+ from quoll.languages.base import FrameworkCI
25
+ from quoll.platforms.github.actions import ACTIONS, gha_cache_scope
26
+ from quoll.platforms.github.platform import GATE_CHECK
27
+ from quoll.platforms.github.workflow import checkout, dump, render_steps
28
+ from quoll.services import SERVICES
29
+ from quoll.workspace import Member, Repo
30
+
31
+ WORKFLOW_PATH = Path(".github/workflows/ci.yml")
32
+ DEPENDABOT_PATH = Path(".github/dependabot.yml")
33
+ # For your own workflows: `- uses: ./.github/actions/quoll-setup` after the checkout (one per member of a
34
+ # workspace: quoll-setup-<member>).
35
+ SETUP_ACTION_PATH = Path(".github/actions/quoll-setup/action.yml")
36
+
37
+
38
+ def render_workflow(target: Union[CIConfig, Repo]) -> str:
39
+ """The CI workflow of a project, or of a workspace (one set of jobs per member, run when it changed)."""
40
+ repo = target if isinstance(target, Repo) else Repo.single(target)
41
+ jobs: Dict[str, Any] = {}
42
+ if repo.workspace:
43
+ jobs["changes"] = _changes_job(repo)
44
+ for member in repo.members:
45
+ member_jobs = _project_jobs(member, repo)
46
+ if repo.workspace:
47
+ member_jobs = {
48
+ name: _guard(job, [f"needs.changes.outputs.{member.name} == 'true'"], needs=["changes"])
49
+ for name, job in member_jobs.items()
50
+ }
51
+ jobs.update(member_jobs)
52
+ if repo.workspace:
53
+ # Repository-wide scans, once.
54
+ jobs.update(_repo_jobs(repo))
55
+ if not [name for name in jobs if name != "changes"]:
56
+ raise ValueError("every CI job is disabled — enable at least one in quoll.toml")
57
+
58
+ if repo.schedule.enabled:
59
+ # Scheduled runs only re-check security: the code didn't change, the vulnerability databases did.
60
+ jobs = {
61
+ name: job if name == "changes" or _kind(name, repo) in SECURITY_JOBS else _skip_on_schedule(job)
62
+ for name, job in jobs.items()
63
+ }
64
+
65
+ jobs["ci-result"] = {
66
+ "name": GATE_CHECK,
67
+ "if": "${{ always() }}",
68
+ "needs": list(jobs),
69
+ "runs-on": "ubuntu-latest",
70
+ "timeout-minutes": 5,
71
+ "steps": [
72
+ {
73
+ "name": "Check job results",
74
+ "if": "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}",
75
+ "run": "exit 1",
76
+ }
77
+ ],
78
+ }
79
+
80
+ doc: Dict[str, Any] = {
81
+ "name": "CI",
82
+ "on": {
83
+ "push": {"branches": list(repo.branches)},
84
+ "pull_request": None,
85
+ "merge_group": None,
86
+ **({"schedule": [{"cron": repo.schedule.cron}]} if repo.schedule.enabled else {}),
87
+ "workflow_dispatch": None,
88
+ },
89
+ "permissions": {"contents": "read"},
90
+ "concurrency": {
91
+ "group": "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}",
92
+ "cancel-in-progress": "${{ github.event_name == 'pull_request' }}",
93
+ },
94
+ }
95
+ if not repo.workspace:
96
+ env = _framework_env(repo.members[0].cfg)
97
+ if env:
98
+ doc["env"] = env
99
+ doc["jobs"] = jobs
100
+ return header("ci") + dump(doc)
101
+
102
+
103
+ def _project_jobs(member: Member, repo: Repo) -> Dict[str, Any]:
104
+ """The jobs of one project. Alone in the repository, it also carries the repository-wide scans, in their
105
+ historical place."""
106
+ cfg = member.cfg
107
+ ci = cfg.language.ci
108
+ framework = cfg.get_framework()
109
+ framework_ci = framework.ci if framework else FrameworkCI()
110
+ default_version = cfg.versions[-1] if cfg.versions else None
111
+ directory = cfg.directory
112
+ # In a workspace, each project's framework settings go to its own jobs.
113
+ env = _framework_env(cfg) if repo.workspace else None
114
+ jobs: Dict[str, Any] = {}
115
+
116
+ def add(kind: str, label: str, timeout: int, steps: Sequence[Item], **options: Any) -> None:
117
+ name = f"{member.name}: {label}" if member.name else label
118
+ options["env"] = {**(env or {}), **(options.get("env") or {})} or None
119
+ jobs[member.job(kind)] = _job(name, timeout, steps, directory=directory, **options)
120
+
121
+ if cfg.lint.enabled and cfg.lint.tools:
122
+ own = [t for t in cfg.lint.tools if cfg.language.is_lint_tool(t)]
123
+ lint: List[Item] = list(ci.lint(cfg, own))
124
+ for tool in cfg.lint.tools:
125
+ if tool not in own:
126
+ lint.extend(framework_ci.lint(cfg, tool))
127
+ add("lint", "Lint", 10, [checkout(), *ci.setup(cfg, default_version), *custom.extend(cfg, "lint", lint)])
128
+
129
+ if cfg.test.enabled:
130
+ service_env = cfg.service_environment("127.0.0.1")
131
+ if service_env:
132
+ # The test databases win over secrets of the same name: tests must never hit a real database.
133
+ secrets = [s for s in cfg.test.env_secrets if s not in service_env]
134
+ cfg = cfg.model_copy(update={"test": cfg.test.model_copy(update={"env_secrets": secrets})})
135
+ options: Dict[str, Any] = {}
136
+ if cfg.services:
137
+ options["services"] = _service_containers(cfg)
138
+ options["env"] = service_env
139
+ label = "Test"
140
+ version = default_version
141
+ if cfg.matrix:
142
+ key = ci.version_key
143
+ label = f"Test (${{{{ matrix.{key} }}}})"
144
+ version = f"${{{{ matrix.{key} }}}}"
145
+ options["strategy"] = {"fail-fast": False, "matrix": {key: list(cfg.versions)}}
146
+ tests = framework_ci.test(cfg) if cfg.is_framework_runner else ci.test(cfg)
147
+ steps: List[Item] = [checkout(), *ci.setup(cfg, version), *custom.extend(cfg, "test", tests)]
148
+ junit = ci.reports(cfg).junit if cfg.test.reports else ""
149
+ if junit:
150
+ # Failed tests annotated on the pull request, results in the job summary.
151
+ steps.append(
152
+ step(
153
+ "Test report",
154
+ uses=ACTIONS["junit-report"],
155
+ with_={"report_paths": cfg.path(junit), "annotate_only": True, "detailed_summary": True},
156
+ always=True,
157
+ )
158
+ )
159
+ if cfg.test.coverage and cfg.test.codecov:
160
+ steps.append(
161
+ step(
162
+ "Upload coverage to Codecov",
163
+ uses=ACTIONS["codecov"],
164
+ with_={"files": cfg.path(ci.coverage_file(cfg)), "token": "${{ secrets.CODECOV_TOKEN }}"},
165
+ always=True,
166
+ )
167
+ )
168
+ add("test", label, 20, steps, **options)
169
+
170
+ if cfg.security.audit:
171
+ audited = cfg if ci.audit_uses_cache else cfg.model_copy(update={"cache": False})
172
+ setup = ci.setup(audited, default_version) if ci.audit_needs_setup else []
173
+ add("audit", "Dependency audit", 10, [checkout(), *setup, *custom.extend(cfg, "audit", ci.audit(cfg))])
174
+
175
+ if not repo.workspace and repo.secrets:
176
+ jobs["secrets"] = _secrets_job()
177
+
178
+ if cfg.security.sbom or cfg.security.licenses:
179
+ sec = cfg.security
180
+ sbom_steps: List[Item] = [
181
+ checkout(),
182
+ *ci.setup(cfg, default_version),
183
+ *custom.extend(
184
+ cfg, "sbom", [*ci.install(cfg), *dependency_checks(sec.sbom, sec.licenses, sec.license_severity)]
185
+ ),
186
+ ]
187
+ if sec.sbom:
188
+ artifact = f"{member.name}-sbom" if member.name else "sbom"
189
+ sbom_steps.append(
190
+ step(
191
+ "Upload SBOM",
192
+ uses=ACTIONS["upload-artifact"],
193
+ with_={"name": artifact, "path": cfg.path(SBOM_FILE)},
194
+ always=True,
195
+ )
196
+ )
197
+ add("sbom", "SBOM and licenses" if sec.licenses else "SBOM", 15, sbom_steps)
198
+
199
+ if not repo.workspace and repo.sast:
200
+ jobs["sast"] = _sast_job()
201
+
202
+ if cfg.security.codeql:
203
+ init: Dict[str, Any] = {"languages": ci.codeql_language, "build-mode": ci.codeql_build_mode}
204
+ if directory != ".":
205
+ init["source-root"] = directory
206
+ codeql_steps: List[Item] = [checkout()]
207
+ if ci.codeql_build_mode != "none":
208
+ codeql_steps += ci.setup(cfg, default_version)
209
+ codeql_steps.append(step("Initialize CodeQL", uses=ACTIONS["codeql-init"], with_=init))
210
+ if ci.codeql_build_mode == "manual":
211
+ # CodeQL watches the language's own build.
212
+ codeql_steps += ci.build(cfg)
213
+ codeql_steps.append(step("Analyze", uses=ACTIONS["codeql-analyze"]))
214
+ add("codeql", "CodeQL", 30, codeql_steps, permissions={"contents": "read", "security-events": "write"})
215
+
216
+ if cfg.security.sonar:
217
+ sonar = step(
218
+ "SonarCloud scan",
219
+ uses=ACTIONS["sonar"],
220
+ with_=_sonar_inputs(cfg),
221
+ env={"SONAR_TOKEN": "${{ secrets.SONAR_TOKEN }}"},
222
+ )
223
+ add(
224
+ "sonar",
225
+ "SonarCloud",
226
+ 15,
227
+ [checkout(fetch_depth=0), sonar],
228
+ # Secrets are not available to pull requests from forks.
229
+ if_=(
230
+ "${{ github.event_name != 'pull_request' || "
231
+ "github.event.pull_request.head.repo.full_name == github.repository }}"
232
+ ),
233
+ )
234
+
235
+ if cfg.build.enabled:
236
+ build_steps = [*framework_ci.before_build(cfg), *ci.build(cfg)]
237
+ build_steps = custom.extend(cfg, "build", build_steps)
238
+ if cfg.build.artifacts:
239
+ build_steps.append(step("Keep the build output", uses=ACTIONS["upload-artifact"], with_={
240
+ "name": member.job("build"),
241
+ "path": "\n".join(cfg.path(p) for p in cfg.build.artifacts),
242
+ "retention-days": 7,
243
+ }))
244
+ add("build", "Build", 15, [checkout(), *ci.setup(cfg, default_version), *build_steps])
245
+
246
+ if cfg.build.chart:
247
+ helm = step("Set up Helm", uses=ACTIONS["setup-helm"], with_={"version": f"v{HELM_VERSION}"})
248
+ add("chart", "Helm chart", 10, [checkout(), helm, *chart_checks(cfg.build.chart)])
249
+
250
+ if cfg.build.docker:
251
+ tag = f"{member.name or 'ci'}:${{{{ github.sha }}}}"
252
+ # Scripts read the commit from the environment, not from an expression pasted into them.
253
+ lint_image, build, scan = image_checks(f"{member.name or 'ci'}:$GITHUB_SHA")
254
+ build = step(
255
+ build.name,
256
+ uses=ACTIONS["build-push"],
257
+ with_={
258
+ "context": directory,
259
+ "load": True,
260
+ "tags": tag,
261
+ "cache-from": gha_cache_scope(member.name),
262
+ "cache-to": f"{gha_cache_scope(member.name)},mode=max",
263
+ },
264
+ )
265
+ add(
266
+ "docker",
267
+ "Docker image",
268
+ 20,
269
+ [checkout(), step("Set up Docker Buildx", uses=ACTIONS["setup-buildx"]), lint_image, build, scan],
270
+ )
271
+ return jobs
272
+
273
+
274
+ def _repo_jobs(repo: Repo) -> Dict[str, Any]:
275
+ jobs: Dict[str, Any] = {}
276
+ if repo.secrets:
277
+ jobs["secrets"] = _secrets_job()
278
+ if repo.sast:
279
+ jobs["sast"] = _sast_job()
280
+ return jobs
281
+
282
+
283
+ def _secrets_job() -> Dict[str, Any]:
284
+ return _job("Secret scanning", 10, [checkout(fetch_depth=0), secret_scan()], container=GITLEAKS_IMAGE)
285
+
286
+
287
+ def _sast_job() -> Dict[str, Any]:
288
+ return _job("Static analysis", 15, [checkout(), sast()], container=SEMGREP_IMAGE)
289
+
290
+
291
+ # Whatever touches every member: the workspace's quoll.toml and the CI itself.
292
+ _SHARED_PATHS = r"quoll\.toml$|\.github/"
293
+
294
+ _CHANGES = """case "$EVENT" in
295
+ pull_request) base="$PR_BASE" ;;
296
+ merge_group) base="$QUEUE_BASE" ;;
297
+ push) base="$PUSH_BEFORE" ;;
298
+ *) base="" ;;
299
+ esac
300
+ # A new branch, a scheduled or a manual run: everything counts as changed.
301
+ if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
302
+ changed=$(git diff --name-only "$base" HEAD)
303
+ else
304
+ changed=$(git ls-files)
305
+ fi
306
+ check() {
307
+ if printf '%s\\n' "$changed" | grep -qE "$2"; then echo "$1=true"; else echo "$1=false"; fi >> "$GITHUB_OUTPUT"
308
+ grep "^$1=" "$GITHUB_OUTPUT"
309
+ }"""
310
+
311
+
312
+ def _changes_job(repo: Repo) -> Dict[str, Any]:
313
+ """Which members a push or a pull request touches: the others' jobs are skipped."""
314
+ checks = []
315
+ for member in repo.members:
316
+ directory = member.directory
317
+ own = "" if directory == "." else re.escape(directory) + "/|"
318
+ pattern = "." if directory == "." else f"^({own}{_SHARED_PATHS})"
319
+ checks.append(f"check {member.name} '{pattern}'")
320
+ detect = step(
321
+ "Changed projects",
322
+ run=_CHANGES + "\n" + "\n".join(checks),
323
+ id_="diff",
324
+ env={
325
+ "EVENT": "${{ github.event_name }}",
326
+ "PR_BASE": "${{ github.event.pull_request.base.sha }}",
327
+ "QUEUE_BASE": "${{ github.event.merge_group.base_sha }}",
328
+ "PUSH_BEFORE": "${{ github.event.before }}",
329
+ },
330
+ )
331
+ job = _job("Changes", 5, [checkout(fetch_depth=0), detect])
332
+ outputs = {m.name: f"${{{{ steps.diff.outputs.{m.name} }}}}" for m in repo.members}
333
+ return {k: v for k, v in [*list(job.items())[:3], ("outputs", outputs), *list(job.items())[3:]]}
334
+
335
+
336
+ def _kind(job: str, repo: Repo) -> str:
337
+ """The generated job a (possibly prefixed) job name stands for: backend-test -> test."""
338
+ for member in repo.members:
339
+ if member.name and job.startswith(f"{member.name}-"):
340
+ return job[len(member.name) + 1 :]
341
+ return job
342
+
343
+
344
+ def _framework_env(cfg: CIConfig) -> Dict[str, str]:
345
+ framework = cfg.get_framework()
346
+ return (framework.ci if framework else FrameworkCI()).env(cfg)
347
+
348
+
349
+ def render_dependabot(target: Union[CIConfig, Repo], docker: Sequence[str] = ()) -> str:
350
+ """Dependency, GitHub Actions and base image updates (in `docker`, the directories holding a Dockerfile)."""
351
+ repo = target if isinstance(target, Repo) else Repo.single(target)
352
+ env = Environment(
353
+ loader=FileSystemLoader(str(Path(__file__).parent)), keep_trailing_newline=True, trim_blocks=True
354
+ )
355
+ ecosystems = []
356
+ for member in repo.members:
357
+ ecosystem = member.cfg.language.ci.dependabot_ecosystem(member.cfg)
358
+ if ecosystem:
359
+ ecosystems.append((ecosystem, _dependabot_directory(member.directory)))
360
+ directories = [_dependabot_directory(d) for d in docker]
361
+ return env.get_template("dependabot.yml.j2").render(ecosystems=ecosystems, docker=directories)
362
+
363
+
364
+ def _dependabot_directory(directory: str) -> str:
365
+ return "/" if directory == "." else f"/{directory}"
366
+
367
+
368
+ def setup_action_path(member: Member) -> Path:
369
+ name = f"quoll-setup-{member.name}" if member.name else "quoll-setup"
370
+ return Path(f".github/actions/{name}/action.yml")
371
+
372
+
373
+ def render_setup_action(cfg: CIConfig) -> str:
374
+ """A composite action giving your own jobs the project's environment: toolchain, caches, dependencies."""
375
+ ci = cfg.language.ci
376
+ action: Dict[str, Any] = {
377
+ "name": "quoll setup",
378
+ "description": f"Set up {cfg.target} and install the dependencies, as quoll's CI does. Use after checkout.",
379
+ }
380
+ if cfg.versions:
381
+ action["inputs"] = {
382
+ "version": {
383
+ "description": f"{ci.version_key} (default: the latest of [ci] versions)",
384
+ "default": cfg.versions[-1],
385
+ }
386
+ }
387
+ version: Optional[str] = "${{ inputs.version }}"
388
+ else:
389
+ version = None # read from [ci] version_file
390
+ steps = render_steps([*ci.setup(cfg, version), *ci.install(cfg)], cfg.directory)
391
+ # Composite actions name the shell (and the directory) of each command.
392
+ where = {"working-directory": cfg.directory} if cfg.directory != "." else {}
393
+ action["runs"] = {
394
+ "using": "composite",
395
+ "steps": [{**s, "shell": "bash", **where} if "run" in s else s for s in steps],
396
+ }
397
+ env = (cfg.get_framework().ci if cfg.get_framework() else FrameworkCI()).env(cfg)
398
+ if env:
399
+ settings = {"name": "Framework settings", "run": _export_env(env), "shell": "bash", **where}
400
+ action["runs"]["steps"].insert(0, settings)
401
+ return header("ci") + dump(action)
402
+
403
+
404
+ def _export_env(env: Dict[str, str]) -> str:
405
+ return "\n".join(f'echo "{k}={v}" >> "$GITHUB_ENV"' for k, v in env.items())
406
+
407
+
408
+ def plan(project_dir: Path, target: Union[CIConfig, Repo]) -> List[PlannedFile]:
409
+ repo = target if isinstance(target, Repo) else Repo.single(target)
410
+ files = [planned(project_dir, WORKFLOW_PATH, render_workflow(repo))]
411
+ files += [planned(project_dir, setup_action_path(m), render_setup_action(m.cfg)) for m in repo.members]
412
+ # Dependabot config is often customised by hand: only create it, never overwrite it.
413
+ if repo.dependency_updates and not (project_dir / DEPENDABOT_PATH).exists():
414
+ docker = [m.directory for m in repo.members if (project_dir / m.directory / "Dockerfile").exists()]
415
+ files.append(planned(project_dir, DEPENDABOT_PATH, render_dependabot(repo, docker), create_only=True))
416
+ return files
417
+
418
+
419
+ def _job(
420
+ name: str,
421
+ timeout: int,
422
+ steps: Sequence[Item],
423
+ *,
424
+ container: Optional[str] = None,
425
+ permissions: Optional[Dict[str, str]] = None,
426
+ if_: Optional[str] = None,
427
+ strategy: Optional[Dict[str, Any]] = None,
428
+ services: Optional[Dict[str, Any]] = None,
429
+ env: Optional[Dict[str, str]] = None,
430
+ directory: str = ".",
431
+ ) -> Dict[str, Any]:
432
+ job: Dict[str, Any] = {"name": name}
433
+ if if_:
434
+ job["if"] = if_
435
+ job["runs-on"] = "ubuntu-latest"
436
+ if container:
437
+ job["container"] = container
438
+ if permissions:
439
+ job["permissions"] = permissions
440
+ job["timeout-minutes"] = timeout
441
+ if strategy:
442
+ job["strategy"] = strategy
443
+ if services:
444
+ job["services"] = services
445
+ if env:
446
+ job["env"] = env
447
+ if directory != ".":
448
+ # Commands run in the project's directory; actions' paths are given from the repository root.
449
+ job["defaults"] = {"run": {"working-directory": directory}}
450
+ job["steps"] = render_steps(steps, directory)
451
+ return job
452
+
453
+
454
+ def _skip_on_schedule(job: Dict[str, Any]) -> Dict[str, Any]:
455
+ return _guard(job, ["github.event_name != 'schedule'"])
456
+
457
+
458
+ def _guard(job: Dict[str, Any], conditions: List[str], needs: Optional[List[str]] = None) -> Dict[str, Any]:
459
+ """`job` running only when `conditions` hold (after `needs`), on top of its own condition (e.g. SonarCloud's
460
+ fork check)."""
461
+ current = job.get("if", "")
462
+ if current:
463
+ conditions = [*conditions, f"({current[len('${{ '):-len(' }}')]})"]
464
+ rest = {k: v for k, v in job.items() if k not in ("name", "needs", "if")}
465
+ guarded: Dict[str, Any] = {"name": job["name"]}
466
+ all_needs = [*(needs or []), *[n for n in job.get("needs", []) if n not in (needs or [])]]
467
+ if all_needs:
468
+ guarded["needs"] = all_needs
469
+ guarded["if"] = "${{ " + " && ".join(conditions) + " }}"
470
+ guarded.update(rest)
471
+ return guarded
472
+
473
+
474
+ def _service_containers(cfg: CIConfig) -> Dict[str, Any]:
475
+ containers: Dict[str, Any] = {}
476
+ for name in cfg.services:
477
+ service = SERVICES[name]
478
+ container: Dict[str, Any] = {"image": service.image}
479
+ if service.env:
480
+ container["env"] = service.env
481
+ container["ports"] = [f"{service.port}:{service.port}"]
482
+ # Steps start once the service is healthy.
483
+ container["options"] = (
484
+ f'--health-cmd "{service.health}" --health-interval 5s --health-timeout 5s --health-retries 20'
485
+ )
486
+ containers[name] = container
487
+ return containers
488
+
489
+
490
+ def _sonar_inputs(cfg: CIConfig) -> Dict[str, Any]:
491
+ args = f"-Dsonar.organization={cfg.security.sonar_organization}"
492
+ if cfg.security.sonar_project_key:
493
+ args = f"-Dsonar.projectKey={cfg.security.sonar_project_key} {args}"
494
+ inputs: Dict[str, Any] = {"args": args}
495
+ if cfg.directory != ".":
496
+ inputs["projectBaseDir"] = cfg.directory
497
+ return inputs
@@ -0,0 +1,49 @@
1
+ version: 2
2
+ updates:
3
+ {% for ecosystem, directory in ecosystems %}
4
+ - package-ecosystem: "{{ ecosystem }}"
5
+ directory: "{{ directory }}"
6
+ schedule:
7
+ interval: "weekly"
8
+ # A new release waits a week: compromised versions are usually pulled by then.
9
+ cooldown:
10
+ default-days: 7
11
+ open-pull-requests-limit: 5
12
+ groups:
13
+ minor-and-patch:
14
+ update-types: ["minor", "patch"]
15
+ {% if ecosystem == "npm" %}
16
+ ignore:
17
+ # Node's types follow the runtime's major (.nvmrc), not the latest one.
18
+ - dependency-name: "@types/node"
19
+ update-types: ["version-update:semver-major"]
20
+ {% endif %}
21
+
22
+ {% endfor %}
23
+ {% for directory in docker %}
24
+ - package-ecosystem: "docker"
25
+ directory: "{{ directory }}"
26
+ schedule:
27
+ interval: "weekly"
28
+ # A new release waits a week: compromised versions are usually pulled by then.
29
+ cooldown:
30
+ default-days: 7
31
+ groups:
32
+ base-images:
33
+ patterns: ["*"]
34
+ ignore:
35
+ # The runtime's major is quoll.toml's ([docker] version): change it there, then `quoll docker update`.
36
+ - dependency-name: "*"
37
+ update-types: ["version-update:semver-major"]
38
+
39
+ {% endfor %}
40
+ - package-ecosystem: "github-actions"
41
+ directory: "/"
42
+ schedule:
43
+ interval: "weekly"
44
+ # A new release waits a week: compromised versions are usually pulled by then.
45
+ cooldown:
46
+ default-days: 7
47
+ groups:
48
+ actions:
49
+ patterns: ["*"]
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, List, Optional, Union
5
+
6
+ from quoll.platforms.base import Platform, PlatformAPI, Remote
7
+
8
+ if TYPE_CHECKING:
9
+ from quoll.cd.config import CDConfig
10
+ from quoll.ci.config import CIConfig
11
+ from quoll.files import PlannedFile
12
+ from quoll.workspace import Delivery, Repo
13
+
14
+
15
+ # The aggregate job of ci.yml to mark as required in branch protection: one stable name,
16
+ # whatever jobs or matrix entries the workflow contains.
17
+ GATE_CHECK = "CI result"
18
+
19
+
20
+ class GitHub(Platform):
21
+ name = "github"
22
+ label = "GitHub"
23
+ secrets_label = "secrets"
24
+ registries = ("ghcr", "dockerhub")
25
+ required_checks = (GATE_CHECK,)
26
+
27
+ def ci_files(self, project_dir: Path, cfg: Union[CIConfig, Repo]) -> List[PlannedFile]:
28
+ from quoll.platforms.github import ci
29
+
30
+ return ci.plan(project_dir, cfg)
31
+
32
+ def cd_files(self, project_dir: Path, cfg: Union[CDConfig, List[Delivery]]) -> List[PlannedFile]:
33
+ from quoll.platforms.github import cd
34
+
35
+ return cd.plan(project_dir, cfg)
36
+
37
+ def default_image(self, remote: Optional[Remote], registry: str, fallback_name: str, member: str = "") -> str:
38
+ path = (remote.path if remote else f"your-org/{fallback_name}").lower()
39
+ if member:
40
+ path += f"-{member}"
41
+ return f"ghcr.io/{path}" if registry == "ghcr" else path
42
+
43
+ def registry_secrets(self, registry: str) -> List[str]:
44
+ return ["DOCKERHUB_USERNAME", "DOCKERHUB_TOKEN"] if registry == "dockerhub" else []
45
+
46
+ def merge_gate(self) -> str:
47
+ return f"Required status check for branch protection: {GATE_CHECK}"
48
+
49
+ def api(self, host: Optional[str] = None) -> PlatformAPI:
50
+ from quoll.platforms.github.api import GitHubAPI
51
+
52
+ return GitHubAPI()
@@ -0,0 +1,74 @@
1
+ """Building blocks of GitHub Actions workflows: neutral steps rendered as workflow steps, emitted by quoll.yaml_dump."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Sequence
5
+
6
+ from quoll import yaml_dump
7
+ from quoll.ci.steps import Cache, Item, Step, step
8
+ from quoll.platforms.github.actions import ACTIONS, PINS
9
+ from quoll.yaml_dump import Commented
10
+
11
+
12
+ def render_steps(items: Sequence[Item], directory: str = ".") -> List[Dict[str, Any]]:
13
+ """Workflow steps; caches of a project in `directory` get their paths from the repository root."""
14
+ return [_cache(item, directory) if isinstance(item, Cache) else _step(item) for item in items]
15
+
16
+
17
+ def _step(s: Step) -> Dict[str, Any]:
18
+ d: Dict[str, Any] = {}
19
+ if s.name:
20
+ d["name"] = s.name
21
+ if s.id:
22
+ d["id"] = s.id
23
+ if s.always:
24
+ d["if"] = "${{ !cancelled() }}"
25
+ if s.uses:
26
+ d["uses"] = pin(s.uses)
27
+ if s.with_:
28
+ d["with"] = dict(s.with_)
29
+ else:
30
+ d["run"] = s.run
31
+ env = {**s.env, **{name: f"${{{{ secrets.{name} }}}}" for name in s.secrets}}
32
+ if env:
33
+ d["env"] = env
34
+ if s.optional:
35
+ d["continue-on-error"] = True
36
+ return d
37
+
38
+
39
+ def _cache(c: Cache, directory: str = ".") -> Dict[str, Any]:
40
+ # Container platforms need `c.env` to keep tool caches in the project; runners use the default locations.
41
+ def within(path: str) -> str:
42
+ if directory == ".":
43
+ return path
44
+ return f"!{directory}/{path[1:]}" if path.startswith("!") else f"{directory}/{path}"
45
+
46
+ prefix = f"{c.key}-${{{{ runner.os }}}}-"
47
+ locked = prefix + _hash([within(f) for f in c.key_files]) if c.key_files else prefix.rstrip("-")
48
+ with_: Dict[str, Any] = {"path": "\n".join(within(p) for p in c.paths)}
49
+ if c.revision_files:
50
+ with_["key"] = f"{locked}-{_hash([within(f) for f in c.revision_files])}"
51
+ with_["restore-keys"] = f"{locked}-"
52
+ else:
53
+ with_["key"] = locked
54
+ return {"name": c.name or f"Cache {c.key}", "uses": pin(ACTIONS["cache"]), "with": with_}
55
+
56
+
57
+ def pin(action: str) -> str:
58
+ """owner/repo@<commit> # <version> for the actions quoll knows; others (plugins) as written."""
59
+ if action not in PINS:
60
+ return action
61
+ path, version = action.split("@")
62
+ return Commented(f"{path}@{PINS[action]}", version)
63
+
64
+
65
+ def _hash(patterns: Sequence[str]) -> str:
66
+ return "${{ hashFiles(" + ", ".join(f"'{p}'" for p in patterns) + ") }}"
67
+
68
+
69
+ def checkout(**with_: Any) -> Step:
70
+ return step("", uses=ACTIONS["checkout"], with_={k.replace("_", "-"): v for k, v in with_.items()})
71
+
72
+
73
+ def dump(doc: Dict[str, Any]) -> str:
74
+ return yaml_dump.dump(doc, spaced=("jobs", "steps"))
@@ -0,0 +1,4 @@
1
+ """GitLab: GitLab CI pipelines, Renovate, the project's container registry, and the REST API."""
2
+ from quoll.platforms.gitlab.platform import GitLab
3
+
4
+ platform = GitLab()