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.
@@ -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')