generic-gitlab-cicd 0.3.2__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.
- generic_ci/__init__.py +3 -0
- generic_ci/__main__.py +3 -0
- generic_ci/cli.py +110 -0
- generic_ci/compiler.py +242 -0
- generic_ci/config.py +148 -0
- generic_ci/dependencies.py +186 -0
- generic_ci/models.py +189 -0
- generic_ci/runtime.py +482 -0
- generic_ci/sources.py +314 -0
- generic_ci/workflows/__init__.py +1 -0
- generic_ci/workflows/compiler.py +316 -0
- generic_ci/workflows/ecosystems.py +185 -0
- generic_ci/workflows/helm.py +168 -0
- generic_ci/workflows/models.py +222 -0
- generic_ci/workflows/publication.py +82 -0
- generic_ci/workflows/runtime.py +421 -0
- generic_gitlab_cicd-0.3.2.dist-info/METADATA +170 -0
- generic_gitlab_cicd-0.3.2.dist-info/RECORD +21 -0
- generic_gitlab_cicd-0.3.2.dist-info/WHEEL +5 -0
- generic_gitlab_cicd-0.3.2.dist-info/entry_points.txt +2 -0
- generic_gitlab_cicd-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Workspace-aware package preparation and ecosystem version ordering."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import tarfile
|
|
11
|
+
import shutil
|
|
12
|
+
import tomllib
|
|
13
|
+
import yaml
|
|
14
|
+
from packaging.version import Version
|
|
15
|
+
from generic_ci.dependencies import prepared
|
|
16
|
+
from generic_ci.runtime import path_in
|
|
17
|
+
from generic_ci.config import allowed_url
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_version(directory, release):
|
|
21
|
+
source = release['version']
|
|
22
|
+
path = path_in(directory, source['file'])
|
|
23
|
+
return extract_version(path.read_text(), source)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_version(text, source):
|
|
27
|
+
data = json.loads(text) if source['file'].endswith('.json') else tomllib.loads(text)
|
|
28
|
+
for key in source['field'].split('.'):
|
|
29
|
+
data = data[key]
|
|
30
|
+
if not isinstance(data, str):
|
|
31
|
+
raise ValueError('version field must be a string')
|
|
32
|
+
version_key(data, source['file'].endswith('.json'))
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def version_key(value, node=False):
|
|
37
|
+
if not node:
|
|
38
|
+
v = Version(value)
|
|
39
|
+
if v.local:
|
|
40
|
+
raise ValueError('release versions cannot have local suffixes')
|
|
41
|
+
return v
|
|
42
|
+
# SemVer precedence: build metadata does not change precedence.
|
|
43
|
+
match = re.fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?', value)
|
|
44
|
+
if not match:
|
|
45
|
+
raise ValueError(f'invalid SemVer: {value}')
|
|
46
|
+
pre = match[4]
|
|
47
|
+
for sequence in (pre, match[5]):
|
|
48
|
+
if sequence is not None and any(not part for part in sequence.split('.')):
|
|
49
|
+
raise ValueError('empty SemVer identifier')
|
|
50
|
+
if pre and any(part.isdigit() and len(part) > 1 and part.startswith('0') for part in pre.split('.')):
|
|
51
|
+
raise ValueError('numeric SemVer prerelease identifiers cannot have leading zeroes')
|
|
52
|
+
return (int(match[1]), int(match[2]), int(match[3]), pre is None,
|
|
53
|
+
tuple((0, int(part)) if part.isdigit() else (1, part) for part in pre.split('.')) if pre else ())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def node_workspace(directory, root, settings):
|
|
57
|
+
if settings['workspace'] is not None:
|
|
58
|
+
result = path_in(root, settings['workspace'])
|
|
59
|
+
if not directory.is_relative_to(result):
|
|
60
|
+
raise ValueError('Node workspace must contain project directory')
|
|
61
|
+
return result
|
|
62
|
+
for parent in (directory, *directory.parents):
|
|
63
|
+
if not parent.is_relative_to(root):
|
|
64
|
+
break
|
|
65
|
+
manifest = parent / 'package.json'
|
|
66
|
+
if not manifest.is_file():
|
|
67
|
+
continue
|
|
68
|
+
metadata = json.loads(manifest.read_text())
|
|
69
|
+
patterns = metadata.get('workspaces', [])
|
|
70
|
+
if isinstance(patterns, dict):
|
|
71
|
+
patterns = patterns.get('packages', [])
|
|
72
|
+
if (parent / 'pnpm-workspace.yaml').is_file():
|
|
73
|
+
patterns = yaml.safe_load((parent / 'pnpm-workspace.yaml').read_text()).get('packages', [])
|
|
74
|
+
members = {p.resolve() for pattern in patterns if not pattern.startswith('!') for p in parent.glob(pattern)}
|
|
75
|
+
excluded = {p.resolve() for pattern in patterns if pattern.startswith('!') for p in parent.glob(pattern[1:])}
|
|
76
|
+
if directory in members - excluded:
|
|
77
|
+
return parent
|
|
78
|
+
return directory
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@contextlib.contextmanager
|
|
82
|
+
def environment(project, check, root, name, candidates, hosts, out):
|
|
83
|
+
directory = path_in(root, project['path'])
|
|
84
|
+
if check.get('dependencies') is False:
|
|
85
|
+
yield {'manager': 'none'}
|
|
86
|
+
elif project['python'] is not None:
|
|
87
|
+
policy = {**project['python'], **(check.get('dependencies') or {}), 'manager': 'uv', 'python': os.environ.get('PYTHON_VERSION')}
|
|
88
|
+
with prepared(directory, root, policy, candidates, name, hosts, out) as record:
|
|
89
|
+
yield record
|
|
90
|
+
elif project['node'] is not None:
|
|
91
|
+
settings = project['node']
|
|
92
|
+
manager = settings['package_manager']
|
|
93
|
+
workspace = node_workspace(directory, root, settings)
|
|
94
|
+
lock_name = {'npm': 'package-lock.json', 'pnpm': 'pnpm-lock.yaml', 'bun': 'bun.lock'}[manager]
|
|
95
|
+
lock = workspace / lock_name
|
|
96
|
+
if not lock.is_file():
|
|
97
|
+
raise ValueError(f'commit {lock_name} at Node workspace root; binary Bun lockfiles must be migrated')
|
|
98
|
+
metadata = json.loads((workspace / 'package.json').read_text())
|
|
99
|
+
requested = metadata.get('packageManager')
|
|
100
|
+
if requested:
|
|
101
|
+
match = re.fullmatch(r'(npm|pnpm|bun)@([^+]+)(?:\+.*)?', requested)
|
|
102
|
+
if not match or match[1] != manager:
|
|
103
|
+
raise ValueError('packageManager metadata conflicts with selected manager')
|
|
104
|
+
actual = subprocess.check_output([manager, '--version'], text=True).strip()
|
|
105
|
+
if actual != match[2]:
|
|
106
|
+
raise ValueError(f'prepared image needs {requested}; found {actual}; no automatic tool download')
|
|
107
|
+
selected = [c for c in candidates if name in c['projects']]
|
|
108
|
+
if selected and manager == 'pnpm' and (workspace / 'pnpm-workspace.yaml').is_file():
|
|
109
|
+
pnpm_settings = yaml.safe_load((workspace / 'pnpm-workspace.yaml').read_text())
|
|
110
|
+
if pnpm_settings.get('overrides'):
|
|
111
|
+
raise ValueError('candidate overrides with pnpm-workspace.yaml overrides require reconciliation; refusing ambiguous precedence')
|
|
112
|
+
# Require an explicitly configured internal default registry. Scope-specific registries
|
|
113
|
+
# and lifecycle-script network access additionally need the platform network boundary.
|
|
114
|
+
registry = os.environ.get('NPM_CONFIG_REGISTRY') or os.environ.get('npm_config_registry')
|
|
115
|
+
if not registry:
|
|
116
|
+
result = subprocess.run(['npm', 'config', 'get', 'registry'], cwd=workspace, text=True, capture_output=True, check=True)
|
|
117
|
+
registry = result.stdout.strip()
|
|
118
|
+
allowed_url(registry, hosts)
|
|
119
|
+
original = lock.read_bytes()
|
|
120
|
+
manifests = {p: p.read_bytes() for p in [workspace / 'package.json', directory / 'package.json']}
|
|
121
|
+
candidate_dir = tempfile.TemporaryDirectory()
|
|
122
|
+
env = {**os.environ, 'npm_config_registry': registry, 'NPM_CONFIG_REGISTRY': registry,
|
|
123
|
+
'COREPACK_ENABLE_NETWORK': '0', 'CI': 'true'}
|
|
124
|
+
command = ['npm', 'ci'] if manager == 'npm' else [manager, 'install', '--frozen-lockfile']
|
|
125
|
+
try:
|
|
126
|
+
candidate_files = []
|
|
127
|
+
for index, candidate in enumerate(selected):
|
|
128
|
+
clone = Path(candidate_dir.name) / str(index)
|
|
129
|
+
subprocess.run(['git', 'clone', '--no-checkout', candidate['repository'], str(clone)], check=True)
|
|
130
|
+
subprocess.run(['git', 'checkout', '--detach', candidate['commit']], cwd=clone, check=True)
|
|
131
|
+
actual_sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=clone, text=True).strip()
|
|
132
|
+
if actual_sha != candidate['commit']:
|
|
133
|
+
raise ValueError('candidate checkout identity mismatch')
|
|
134
|
+
candidate_root = path_in(clone, candidate['subdirectory'] or '.')
|
|
135
|
+
package_metadata = json.loads((candidate_root / 'package.json').read_text())
|
|
136
|
+
if package_metadata['name'] != candidate['package']:
|
|
137
|
+
raise ValueError('candidate package name does not match package.json')
|
|
138
|
+
packed = clone / 'packed'; packed.mkdir()
|
|
139
|
+
# Candidate repository must carry publishable output. Never run unverified
|
|
140
|
+
# prepare scripts with publishing credentials to silently build it.
|
|
141
|
+
subprocess.run(['npm', 'pack', '--ignore-scripts', '--pack-destination', str(packed)], cwd=candidate_root, env=env, check=True)
|
|
142
|
+
archives = list(packed.glob('*.tgz'))
|
|
143
|
+
if len(archives) != 1:
|
|
144
|
+
raise ValueError('candidate pack must produce exactly one tarball')
|
|
145
|
+
archive = archives[0]
|
|
146
|
+
candidate_files.append((candidate, archive))
|
|
147
|
+
uri = 'file:' + str(archive)
|
|
148
|
+
for path in manifests:
|
|
149
|
+
document = json.loads(path.read_text())
|
|
150
|
+
for section in ('dependencies', 'devDependencies', 'optionalDependencies'):
|
|
151
|
+
if candidate['package'] in document.get(section, {}):
|
|
152
|
+
document[section][candidate['package']] = uri
|
|
153
|
+
if path == workspace / 'package.json':
|
|
154
|
+
owner = document.setdefault('pnpm', {}) if manager == 'pnpm' else document
|
|
155
|
+
owner.setdefault('overrides', {})[candidate['package']] = uri
|
|
156
|
+
path.write_text(json.dumps(document, indent=2) + '\n')
|
|
157
|
+
if selected:
|
|
158
|
+
refresh = ['npm', 'install', '--package-lock-only', '--ignore-scripts'] if manager == 'npm' else [manager, 'install', '--lockfile-only', '--ignore-scripts']
|
|
159
|
+
subprocess.run(refresh, cwd=workspace, env=env, check=True)
|
|
160
|
+
resolved_lock = lock.read_bytes()
|
|
161
|
+
subprocess.run(command, cwd=workspace, env=env, check=True)
|
|
162
|
+
if lock.read_bytes() != resolved_lock:
|
|
163
|
+
raise ValueError('frozen install unexpectedly changed lockfile')
|
|
164
|
+
for candidate, archive in candidate_files:
|
|
165
|
+
locations = [parent / 'node_modules' / candidate['package'] for parent in (directory, *directory.parents) if parent.is_relative_to(workspace)]
|
|
166
|
+
installed = next((p.resolve() for p in locations if (p / 'package.json').is_file()), None)
|
|
167
|
+
if installed is None:
|
|
168
|
+
raise ValueError('candidate is not installed in the consuming project')
|
|
169
|
+
with tarfile.open(archive) as tar:
|
|
170
|
+
for member in tar.getmembers():
|
|
171
|
+
if not member.isfile():
|
|
172
|
+
continue
|
|
173
|
+
relative = Path(member.name).relative_to('package')
|
|
174
|
+
target = path_in(installed, str(relative))
|
|
175
|
+
if not target.is_file() or target.read_bytes() != tar.extractfile(member).read():
|
|
176
|
+
raise ValueError('installed candidate differs from packed candidate commit')
|
|
177
|
+
yield {'manager': manager, 'workspace': workspace.relative_to(root).as_posix(),
|
|
178
|
+
'lock_sha256': hashlib.sha256(resolved_lock).hexdigest(), 'candidates': selected}
|
|
179
|
+
finally:
|
|
180
|
+
candidate_dir.cleanup()
|
|
181
|
+
lock.write_bytes(original)
|
|
182
|
+
for path, content in manifests.items():
|
|
183
|
+
path.write_bytes(content)
|
|
184
|
+
else:
|
|
185
|
+
yield {'manager': 'none'}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Helm image mapping with an explicit baseline for partial shared releases."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import yaml
|
|
10
|
+
from generic_ci.runtime import path_in, write_json
|
|
11
|
+
from generic_ci.compiler import digest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_value(data, path):
|
|
15
|
+
for key in path.split('.'):
|
|
16
|
+
if not isinstance(data, dict) or key not in data:
|
|
17
|
+
raise ValueError(f'previous release has no image value at {path}; use complete deployment')
|
|
18
|
+
data = data[key]
|
|
19
|
+
return data
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_value(data, path, value):
|
|
23
|
+
parts = path.split('.')
|
|
24
|
+
for key in parts[:-1]:
|
|
25
|
+
data = data.setdefault(key, {})
|
|
26
|
+
data[parts[-1]] = value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def overlay_images(bindings, images, previous, partial):
|
|
30
|
+
overlay = {}
|
|
31
|
+
for binding in bindings:
|
|
32
|
+
project = binding['from_'].split('.')[0]
|
|
33
|
+
image = images.get(project)
|
|
34
|
+
for field, path in binding['set'].items():
|
|
35
|
+
if path is None:
|
|
36
|
+
continue
|
|
37
|
+
if image is not None:
|
|
38
|
+
value = image[field]
|
|
39
|
+
elif partial:
|
|
40
|
+
value = get_value(previous, path)
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f'missing image build for {project}')
|
|
43
|
+
if not isinstance(value, str) or not value:
|
|
44
|
+
raise ValueError(f'image value at {path} must be a nonempty string')
|
|
45
|
+
set_value(overlay, path, value)
|
|
46
|
+
return overlay
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_forward_commit(root, previous, current):
|
|
50
|
+
"""Recover shallow history before distinguishing stale commits from Git errors."""
|
|
51
|
+
if not all(re.fullmatch(r'[a-f0-9]{40}', value) for value in (previous, current)):
|
|
52
|
+
raise ValueError('deployment history requires full Git commit IDs')
|
|
53
|
+
command = ['git', 'merge-base', '--is-ancestor', previous, current]
|
|
54
|
+
result = subprocess.run(command, cwd=root, capture_output=True)
|
|
55
|
+
if result.returncode:
|
|
56
|
+
shallow = subprocess.check_output(['git', 'rev-parse', '--is-shallow-repository'], cwd=root, text=True).strip()
|
|
57
|
+
if shallow == 'true':
|
|
58
|
+
subprocess.run(['git', 'fetch', '--unshallow', '--no-tags', 'origin'], cwd=root, check=True, capture_output=True)
|
|
59
|
+
result = subprocess.run(command, cwd=root, capture_output=True)
|
|
60
|
+
if result.returncode == 1:
|
|
61
|
+
raise ValueError('stale/nonlinear image update; explicit rollback or reconciliation required')
|
|
62
|
+
if result.returncode:
|
|
63
|
+
raise ValueError('cannot verify deployment ancestry; ensure both commits are available from origin')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def deploy(data, node, root, out, records, plan, stop=False):
|
|
67
|
+
settings, infra = node['settings'], data['platform']
|
|
68
|
+
target = infra['targets'][settings['target']]
|
|
69
|
+
preview = node['event'] == 'merge-request'
|
|
70
|
+
if not preview and target['production'] and os.environ.get('CI_COMMIT_REF_PROTECTED') != 'true':
|
|
71
|
+
raise ValueError('production requires a protected ref')
|
|
72
|
+
if not preview and plan.get('candidates'):
|
|
73
|
+
raise ValueError('candidate runs cannot update persistent deployments')
|
|
74
|
+
release = target['release'] or node['deployment']
|
|
75
|
+
if preview:
|
|
76
|
+
release += '-mr-' + os.environ['CI_PROJECT_ID'] + '-' + os.environ['CI_MERGE_REQUEST_IID']
|
|
77
|
+
if len(release) > 53:
|
|
78
|
+
release = release[:40].rstrip('-') + '-' + hashlib.sha256(release.encode()).hexdigest()[:12]
|
|
79
|
+
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?', release):
|
|
80
|
+
raise ValueError('invalid Helm release name')
|
|
81
|
+
namespace = os.path.expandvars(target['namespace'])
|
|
82
|
+
env = {**os.environ, 'KUBECONFIG': os.environ[target['kubeconfig_variable']]}
|
|
83
|
+
url = os.path.expandvars(target['url'])
|
|
84
|
+
if stop:
|
|
85
|
+
subprocess.run(['helm', 'uninstall', release, '--namespace', namespace, '--ignore-not-found', '--wait'], env=env, check=True)
|
|
86
|
+
return {'release': release, 'url': url}
|
|
87
|
+
chart = settings['chart']
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
if chart['path']:
|
|
90
|
+
chart_path = path_in(root, chart['path'])
|
|
91
|
+
chart_hash = digest({str(f.relative_to(chart_path)): hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted(chart_path.rglob('*')) if f.is_file()})
|
|
92
|
+
else:
|
|
93
|
+
args = ['helm', 'pull', chart['oci'] or chart['name'], '--version', chart['version'], '--destination', tmp]
|
|
94
|
+
if chart['repository']:
|
|
95
|
+
args += ['--repo', chart['repository']]
|
|
96
|
+
# Registry config and CA are injected as environment/file configuration.
|
|
97
|
+
if os.environ.get('TOOLKIT_HELM_CA_FILE'):
|
|
98
|
+
args += ['--ca-file', os.environ['TOOLKIT_HELM_CA_FILE']]
|
|
99
|
+
if chart['repository'] and os.environ.get('TOOLKIT_HELM_USERNAME'):
|
|
100
|
+
args += ['--username', os.environ['TOOLKIT_HELM_USERNAME'], '--password', os.environ['TOOLKIT_HELM_PASSWORD']]
|
|
101
|
+
subprocess.run(args, env=env, check=True)
|
|
102
|
+
archives = list(Path(tmp).glob('*.tgz'))
|
|
103
|
+
if len(archives) != 1:
|
|
104
|
+
raise ValueError('Helm pull must produce exactly one chart archive')
|
|
105
|
+
chart_path = archives[0]
|
|
106
|
+
chart_hash = hashlib.sha256(chart_path.read_bytes()).hexdigest()
|
|
107
|
+
value_files = [path_in(root, p) for p in settings['values']]
|
|
108
|
+
baseline = digest({'chart': chart_hash, 'values': [hashlib.sha256(f.read_bytes()).hexdigest() for f in value_files], 'bindings': settings['images']})
|
|
109
|
+
listing = json.loads(subprocess.check_output(['helm', 'list', '--all', '--namespace', namespace, '--filter', '^' + re.escape(release) + '$', '--output', 'json'], env=env, text=True))
|
|
110
|
+
previous = {}
|
|
111
|
+
old_meta = {}
|
|
112
|
+
if listing:
|
|
113
|
+
if len(listing) != 1 or listing[0]['status'] != 'deployed':
|
|
114
|
+
raise ValueError('previous Helm release is not deployed; reconcile failed/pending state first')
|
|
115
|
+
previous = json.loads(subprocess.check_output(['helm', 'get', 'values', release, '--namespace', namespace, '--output', 'json'], env=env, text=True)) or {}
|
|
116
|
+
old_meta = previous.get('genericCiMetadata', {})
|
|
117
|
+
partial = settings['update'] == 'partial'
|
|
118
|
+
if partial and listing and old_meta.get('baseline') != baseline:
|
|
119
|
+
raise ValueError('partial update cannot change chart/configuration; perform a complete deployment')
|
|
120
|
+
images = {r['project']: r['image'] for r in records if 'image' in r}
|
|
121
|
+
if not images:
|
|
122
|
+
raise ValueError('no selected images for this deployment')
|
|
123
|
+
for name in images:
|
|
124
|
+
prior = old_meta.get('services', {}).get(name, {})
|
|
125
|
+
old_commit = prior.get('commit')
|
|
126
|
+
current = os.environ['CI_COMMIT_SHA']
|
|
127
|
+
if old_commit and old_commit != current:
|
|
128
|
+
require_forward_commit(root, old_commit, current)
|
|
129
|
+
overlay = overlay_images(settings['images'], images, previous, partial and bool(listing))
|
|
130
|
+
services = dict(old_meta.get('services', {})) if partial else {}
|
|
131
|
+
for name in images:
|
|
132
|
+
services[name] = {'commit': os.environ['CI_COMMIT_SHA'], 'pipeline': os.environ['CI_PIPELINE_ID']}
|
|
133
|
+
overlay['genericCiMetadata'] = {'baseline': baseline, 'services': services}
|
|
134
|
+
# Metadata is Helm values state, not a separate desired-state repository.
|
|
135
|
+
generated = Path(tmp) / 'images.json'
|
|
136
|
+
write_json(generated, overlay)
|
|
137
|
+
args = [release, str(chart_path), '--namespace', namespace]
|
|
138
|
+
for path in value_files:
|
|
139
|
+
args += ['--values', str(path)]
|
|
140
|
+
args += ['--values', str(generated)]
|
|
141
|
+
rendered = subprocess.check_output(['helm', 'template', *args], env=env, text=True)
|
|
142
|
+
workload_images = set()
|
|
143
|
+
def scan(value):
|
|
144
|
+
if isinstance(value, dict):
|
|
145
|
+
for key, child in value.items():
|
|
146
|
+
if key in {'containers', 'initContainers'} and isinstance(child, list):
|
|
147
|
+
workload_images.update(v.get('image') for v in child if isinstance(v, dict))
|
|
148
|
+
scan(child)
|
|
149
|
+
elif isinstance(value, list):
|
|
150
|
+
for child in value:
|
|
151
|
+
scan(child)
|
|
152
|
+
for document in yaml.safe_load_all(rendered):
|
|
153
|
+
scan(document)
|
|
154
|
+
for binding in settings['images']:
|
|
155
|
+
name = binding['from_'].split('.')[0]
|
|
156
|
+
if name not in images:
|
|
157
|
+
continue
|
|
158
|
+
image = images[name]
|
|
159
|
+
expected = image['repository'] + ('@' + image['digest'] if binding['set']['digest'] else ':' + image['tag'])
|
|
160
|
+
if expected not in workload_images:
|
|
161
|
+
raise ValueError(f'chart does not consume mapped image for {name}: {expected}')
|
|
162
|
+
major = subprocess.check_output(['helm', 'version', '--template', '{{.Version}}'], text=True)
|
|
163
|
+
flag = '--atomic' if major.startswith('v3.') else '--rollback-on-failure' if major.startswith('v4.') else None
|
|
164
|
+
if not flag:
|
|
165
|
+
raise ValueError('supported Helm versions: 3 and 4')
|
|
166
|
+
subprocess.run(['helm', 'upgrade', '--install', *args, '--wait', '--timeout', '10m', flag], env=env, check=True)
|
|
167
|
+
write_json(out / 'images.json', images)
|
|
168
|
+
return {'release': release, 'namespace': namespace, 'url': url, 'images': images, 'baseline': baseline}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Public workflow schema. No hidden test names or raw GitLab rules."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field, model_validator, field_validator
|
|
4
|
+
from generic_ci.models import Model, Name, relative
|
|
5
|
+
|
|
6
|
+
class Python(Model):
|
|
7
|
+
groups: Literal['all'] | list[str] | None = Field(default=None, description='Omit to respect uv defaults; all selects all groups, not all workspace members.')
|
|
8
|
+
extras: Literal['all'] | list[str] = Field(default_factory=list)
|
|
9
|
+
upgrade: Literal['none', 'all'] = 'none'
|
|
10
|
+
|
|
11
|
+
class Node(Model):
|
|
12
|
+
package_manager: Literal['npm', 'pnpm', 'bun']
|
|
13
|
+
workspace: str | None = Field(default=None, description='Explicit repository-relative workspace root; otherwise discover membership.')
|
|
14
|
+
_path = field_validator('workspace')(lambda v: relative(v) if v is not None else v)
|
|
15
|
+
|
|
16
|
+
class Execution(Model):
|
|
17
|
+
image: str | None = None
|
|
18
|
+
tags: list[str] | None = None
|
|
19
|
+
timeout: str | None = None
|
|
20
|
+
variables: dict[str, str] = Field(default_factory=dict)
|
|
21
|
+
services: list[str] | None = None
|
|
22
|
+
parallel: dict | None = None
|
|
23
|
+
artifacts: dict = Field(default_factory=dict)
|
|
24
|
+
@field_validator('variables')
|
|
25
|
+
@classmethod
|
|
26
|
+
def variables_safe(cls, v):
|
|
27
|
+
if any(k.startswith(('CI_', 'TOOLKIT_')) or k in {'PYTHONPATH', 'PYTHONHOME', 'DEPLOYMENT_URL'} for k in v):
|
|
28
|
+
raise ValueError('CI_*, TOOLKIT_*, Python bootstrap and DEPLOYMENT_URL variables are reserved')
|
|
29
|
+
return v
|
|
30
|
+
|
|
31
|
+
class Check(Execution):
|
|
32
|
+
script: list[str] = Field(min_length=1, description='Team-owned commands, executed after preparation.')
|
|
33
|
+
dependencies: Python | Literal[False] | None = None
|
|
34
|
+
needs: list[str] = Field(default_factory=list, description='Checks or project.build outputs required in the same event.')
|
|
35
|
+
outputs: list[str] = Field(default_factory=list, description='Project-relative files passed to consumers; matrices cannot produce shared files.')
|
|
36
|
+
_paths = field_validator('outputs')(lambda v: [relative(p) for p in v])
|
|
37
|
+
|
|
38
|
+
class Build(Model):
|
|
39
|
+
script: list[str] = Field(min_length=1)
|
|
40
|
+
outputs: list[str] = Field(min_length=1)
|
|
41
|
+
needs: list[str] = Field(default_factory=list)
|
|
42
|
+
_paths = field_validator('outputs')(lambda v: [relative(p) for p in v])
|
|
43
|
+
|
|
44
|
+
class Container(Model):
|
|
45
|
+
dockerfile: str = 'Dockerfile'
|
|
46
|
+
context: str = '.'
|
|
47
|
+
repository: str | None = None
|
|
48
|
+
needs: list[str] = Field(default_factory=list)
|
|
49
|
+
build_args: dict[str, str] = Field(default_factory=dict)
|
|
50
|
+
secrets: dict[str, str] = Field(default_factory=dict, description='Secret ID to file-type CI variable name.')
|
|
51
|
+
target: str | None = None
|
|
52
|
+
dependency_bundle: bool = Field(default=False, description='Python Dockerfile consumes named ci-dependencies wheelhouse context; required for candidate previews.')
|
|
53
|
+
_paths = field_validator('dockerfile', 'context')(relative)
|
|
54
|
+
|
|
55
|
+
class PreviewPackage(Model):
|
|
56
|
+
index: str | None = Field(default=None, description="Optional Python named uv index override for development publication.")
|
|
57
|
+
registry: str | None = Field(default=None, description="Optional Node registry override for development publication.")
|
|
58
|
+
|
|
59
|
+
@model_validator(mode='after')
|
|
60
|
+
def one_destination(self):
|
|
61
|
+
if bool(self.index) == bool(self.registry):
|
|
62
|
+
raise ValueError('preview requires exactly one of index (Python) or registry (Node)')
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
class Publication(Model):
|
|
66
|
+
channel: Literal["development", "auto"]
|
|
67
|
+
|
|
68
|
+
class Package(Model):
|
|
69
|
+
directory: str = '.'
|
|
70
|
+
preview: PreviewPackage | None = None
|
|
71
|
+
index: str | None = Field(default=None, description='Python named uv publishing index; Node reads publishConfig.registry.')
|
|
72
|
+
needs: list[str] = Field(default_factory=list)
|
|
73
|
+
_path = field_validator('directory')(relative)
|
|
74
|
+
|
|
75
|
+
class VersionSource(Model):
|
|
76
|
+
file: str
|
|
77
|
+
field: str
|
|
78
|
+
_path = field_validator('file')(relative)
|
|
79
|
+
|
|
80
|
+
class CreateRelease(Model):
|
|
81
|
+
branch: str = 'main'
|
|
82
|
+
when: Literal['manual'] = 'manual'
|
|
83
|
+
|
|
84
|
+
class Release(Model):
|
|
85
|
+
version: VersionSource | None = None
|
|
86
|
+
require_bump: bool = True
|
|
87
|
+
tag: str
|
|
88
|
+
create: CreateRelease | None = None
|
|
89
|
+
needs: list[str] = Field(default_factory=list, description='Other projects whose matching release publication must finish first.')
|
|
90
|
+
@field_validator('tag')
|
|
91
|
+
@classmethod
|
|
92
|
+
def pattern(cls, v):
|
|
93
|
+
if v.count('{version}') != 1 or '{' in v.replace('{version}', ''):
|
|
94
|
+
raise ValueError('tag requires exactly one {version}')
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
class Workflow(Model):
|
|
98
|
+
checks: list[str] = Field(default_factory=list)
|
|
99
|
+
build: bool | list[Literal['application', 'container', 'package']] = False
|
|
100
|
+
publish: bool | Publication = False
|
|
101
|
+
|
|
102
|
+
Event = Literal['push', 'merge-request', 'release', 'manual', 'schedule']
|
|
103
|
+
|
|
104
|
+
class Project(Model):
|
|
105
|
+
path: str = '.'
|
|
106
|
+
python: Python | None = None
|
|
107
|
+
node: Node | None = None
|
|
108
|
+
defaults: Execution = Field(default_factory=Execution)
|
|
109
|
+
checks: dict[Name, Check] = Field(default_factory=dict)
|
|
110
|
+
build: Build | None = None
|
|
111
|
+
container: Container | None = None
|
|
112
|
+
package: Package | None = None
|
|
113
|
+
release: Release | None = None
|
|
114
|
+
workflows: dict[Event, Workflow] = Field(default_factory=dict)
|
|
115
|
+
depends_on: list[Name] = Field(default_factory=list, description='Changes to these projects select this consumer. Installation follows package metadata.')
|
|
116
|
+
watch: list[str] = Field(default_factory=list)
|
|
117
|
+
_paths = field_validator('path')(relative)
|
|
118
|
+
@model_validator(mode='after')
|
|
119
|
+
def ecosystem(self):
|
|
120
|
+
if self.python is not None and self.node is not None:
|
|
121
|
+
raise ValueError('use separate project identities for Python and Node outputs sharing a path')
|
|
122
|
+
if self.package and self.package.preview:
|
|
123
|
+
if ((self.python is not None and not self.package.preview.index)
|
|
124
|
+
or (self.node is not None and not self.package.preview.registry)
|
|
125
|
+
or (self.python is None and self.node is None)):
|
|
126
|
+
raise ValueError('preview destination must match the project ecosystem')
|
|
127
|
+
if self.release and not self.release.version:
|
|
128
|
+
if self.python is not None:
|
|
129
|
+
self.release.version = VersionSource(file='pyproject.toml', field='project.version')
|
|
130
|
+
elif self.node is not None:
|
|
131
|
+
self.release.version = VersionSource(file='package.json', field='version')
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError('release.version is required without a Python or Node ecosystem')
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
class Chart(Model):
|
|
137
|
+
path: str | None = None
|
|
138
|
+
repository: str | None = None
|
|
139
|
+
name: str | None = None
|
|
140
|
+
oci: str | None = None
|
|
141
|
+
version: str | None = None
|
|
142
|
+
@model_validator(mode='after')
|
|
143
|
+
def source(self):
|
|
144
|
+
if sum(bool(v) for v in (self.path, self.repository, self.oci)) != 1:
|
|
145
|
+
raise ValueError('choose exactly one chart source: path, repository, oci')
|
|
146
|
+
if self.path:
|
|
147
|
+
relative(self.path)
|
|
148
|
+
if self.name or self.version:
|
|
149
|
+
raise ValueError('local chart cannot specify name/version')
|
|
150
|
+
elif not self.version or (self.repository and not self.name):
|
|
151
|
+
raise ValueError('remote chart requires pinned version; Helm repository also requires name')
|
|
152
|
+
if self.oci and (not self.oci.startswith('oci://') or self.name):
|
|
153
|
+
raise ValueError('OCI chart requires oci:// reference and no name')
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
class Binding(Model):
|
|
157
|
+
repository: str | None = None
|
|
158
|
+
tag: str | None = None
|
|
159
|
+
digest: str | None = None
|
|
160
|
+
@model_validator(mode='after')
|
|
161
|
+
def paths(self):
|
|
162
|
+
import re
|
|
163
|
+
if not self.tag and not self.digest:
|
|
164
|
+
raise ValueError('map at least tag or digest')
|
|
165
|
+
for v in (self.repository, self.tag, self.digest):
|
|
166
|
+
if v and not re.fullmatch(r'[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*', v):
|
|
167
|
+
raise ValueError('image paths support dotted mapping keys; array indices and literal dots are not supported')
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
class Image(Model):
|
|
171
|
+
from_: str = Field(alias='from')
|
|
172
|
+
set: Binding
|
|
173
|
+
|
|
174
|
+
class CheckList(Model):
|
|
175
|
+
checks: list[str] = Field(default_factory=list)
|
|
176
|
+
|
|
177
|
+
class DeployWorkflow(Model):
|
|
178
|
+
when: Literal['manual', 'automatic'] = 'manual'
|
|
179
|
+
|
|
180
|
+
class Deployment(Model):
|
|
181
|
+
target: str = Field(description='Platform target containing namespace, kubeconfig variable and URL.')
|
|
182
|
+
chart: Chart
|
|
183
|
+
values: list[str] = Field(default_factory=list)
|
|
184
|
+
images: list[Image] = Field(min_length=1)
|
|
185
|
+
update: Literal['complete', 'partial'] = 'complete'
|
|
186
|
+
before: CheckList = Field(default_factory=CheckList)
|
|
187
|
+
after: CheckList = Field(default_factory=CheckList)
|
|
188
|
+
workflows: dict[Literal['release', 'merge-request'], DeployWorkflow] = Field(default_factory=dict)
|
|
189
|
+
auto_stop_in: str = '2 days'
|
|
190
|
+
_paths = field_validator('values')(lambda v: [relative(p) for p in v])
|
|
191
|
+
|
|
192
|
+
class Pipeline(Model):
|
|
193
|
+
version: Literal[1] = 1
|
|
194
|
+
projects: dict[Name, Project] = Field(min_length=1)
|
|
195
|
+
deployments: dict[Name, Deployment] = Field(default_factory=dict)
|
|
196
|
+
|
|
197
|
+
class Builder(Model):
|
|
198
|
+
engine: Literal['buildah'] = 'buildah'
|
|
199
|
+
image: str
|
|
200
|
+
tags: list[str] | None = None
|
|
201
|
+
|
|
202
|
+
class Registries(Model):
|
|
203
|
+
containers: str
|
|
204
|
+
previews: str
|
|
205
|
+
|
|
206
|
+
class Target(Model):
|
|
207
|
+
namespace: str
|
|
208
|
+
kubeconfig_variable: str = 'KUBECONFIG'
|
|
209
|
+
url: str = ''
|
|
210
|
+
production: bool = True
|
|
211
|
+
release: str | None = None
|
|
212
|
+
|
|
213
|
+
class Platform(Model):
|
|
214
|
+
version: Literal[1] = 1
|
|
215
|
+
defaults: Execution = Field(default_factory=Execution)
|
|
216
|
+
images: dict[str, str] = Field(description='Prepared images for python, node, bun, helm, and optional control role; all include toolkit Python runtime.')
|
|
217
|
+
container_builder: Builder
|
|
218
|
+
registries: Registries
|
|
219
|
+
allowed_hosts: list[str] = Field(min_length=1)
|
|
220
|
+
targets: dict[Name, Target] = Field(default_factory=dict)
|
|
221
|
+
variables: dict[str, str] = Field(default_factory=dict)
|
|
222
|
+
max_jobs: int = Field(default=150, ge=1, le=1000)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Package version generation and user-selected publication destinations."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
from packaging.version import Version
|
|
8
|
+
import tomlkit
|
|
9
|
+
from generic_ci.config import allowed_url
|
|
10
|
+
from .ecosystems import version_key
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def snapshot_version(value, node=False):
|
|
14
|
+
pipeline = os.environ.get('CI_PIPELINE_ID', '')
|
|
15
|
+
if not re.fullmatch(r'[1-9][0-9]*', pipeline):
|
|
16
|
+
raise ValueError('development versions require a numeric CI_PIPELINE_ID')
|
|
17
|
+
if node:
|
|
18
|
+
version_key(value, True)
|
|
19
|
+
return value.split('-')[0].split('+')[0] + '-dev.' + pipeline
|
|
20
|
+
version = Version(value)
|
|
21
|
+
if version.local or version.post is not None:
|
|
22
|
+
raise ValueError('development builds require a version without local/post suffixes')
|
|
23
|
+
return version.base_version + '.dev' + pipeline
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def release_channel(value, node=False):
|
|
27
|
+
if node:
|
|
28
|
+
version_key(value, True)
|
|
29
|
+
pre = value.split('+')[0].partition('-')[2]
|
|
30
|
+
label = pre.split('.')[0] if pre else 'latest'
|
|
31
|
+
return 'next' if label.isdigit() else label
|
|
32
|
+
version = Version(value)
|
|
33
|
+
if version.is_devrelease:
|
|
34
|
+
return 'dev'
|
|
35
|
+
return {'a': 'alpha', 'b': 'beta', 'rc': 'rc'}[version.pre[0]] if version.pre else 'latest'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def endpoint(value, hosts):
|
|
39
|
+
allowed_url(value, hosts)
|
|
40
|
+
parsed = urlsplit(value)
|
|
41
|
+
return urlunsplit(('https', parsed.netloc.lower().removesuffix(':443'), parsed.path.rstrip('/'), '', ''))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def destination(project, directory, hosts, development=False):
|
|
45
|
+
settings = project['package']
|
|
46
|
+
preview = settings.get('preview') or {}
|
|
47
|
+
if project['python'] is not None:
|
|
48
|
+
rows = tomllib.loads((directory / 'pyproject.toml').read_text()).get('tool', {}).get('uv', {}).get('index', [])
|
|
49
|
+
name = (preview.get('index') or settings['index']) if development else settings['index']
|
|
50
|
+
selected = [row for row in rows if row.get('name') == name and row.get('publish-url')]
|
|
51
|
+
if len(selected) != 1:
|
|
52
|
+
raise ValueError('select one named uv index with publish-url in pyproject.toml')
|
|
53
|
+
row = selected[0]
|
|
54
|
+
url = endpoint(row['publish-url'], hosts)
|
|
55
|
+
return {'index': name, 'url': url, 'check_url': endpoint(row['url'], hosts)}
|
|
56
|
+
meta = json.loads((directory / 'package.json').read_text())
|
|
57
|
+
normal = preview.get('registry') if development else None
|
|
58
|
+
normal = normal or meta.get('publishConfig', {}).get('registry')
|
|
59
|
+
if not normal:
|
|
60
|
+
raise ValueError('package.json publishConfig.registry is required')
|
|
61
|
+
normal = endpoint(normal, hosts)
|
|
62
|
+
return {'url': normal}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rewrite_snapshot(directory, node, target):
|
|
66
|
+
path = directory / ('package.json' if node else 'pyproject.toml')
|
|
67
|
+
meta = json.loads(path.read_text()) if node else tomlkit.parse(path.read_text())
|
|
68
|
+
package = meta if node else meta['project']
|
|
69
|
+
original = package.get('version')
|
|
70
|
+
if not isinstance(original, str):
|
|
71
|
+
raise ValueError('development publication requires a static manifest version')
|
|
72
|
+
package['version'] = snapshot_version(original, node)
|
|
73
|
+
if node:
|
|
74
|
+
# npm can prefer the archive's publishConfig over command-line options.
|
|
75
|
+
meta['publishConfig'] = {**meta.get('publishConfig', {}), 'registry': target['url'], 'tag': 'dev'}
|
|
76
|
+
path.write_text(json.dumps(meta, indent=2) + '\n' if node else tomlkit.dumps(meta))
|
|
77
|
+
return package['version']
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_development_context(plan):
|
|
81
|
+
if plan['event'] == 'release' or os.environ.get('CI_COMMIT_TAG'):
|
|
82
|
+
raise ValueError('development publication generates a snapshot; use auto to publish the tagged version')
|