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
|
@@ -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"))
|