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/sources.py ADDED
@@ -0,0 +1,314 @@
1
+ """Pinned, data-only Git configuration sources; no checkout or source code execution."""
2
+ import argparse
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ from urllib.parse import urlsplit
10
+
11
+ import yaml
12
+ from packaging.specifiers import SpecifierSet
13
+ from packaging.version import Version
14
+ from pydantic import Field
15
+
16
+ from . import __version__
17
+ from .config import read_yaml, UniqueLoader
18
+ from .models import Model, relative
19
+ from .workflows.compiler import merge
20
+ from .workflows.models import Pipeline, Platform
21
+
22
+
23
+ class Source(Model):
24
+ repository: str
25
+ ref: str
26
+
27
+
28
+ class ProjectConfig(Model):
29
+ source: Source
30
+ delivery: str = 'delivery.yml'
31
+ platform: str | None = None
32
+
33
+
34
+ class Manifest(Model):
35
+ version: int = 1
36
+ cli: str = '>=0.3.0,<0.4.0'
37
+ defaults: dict[str, str] = Field(default_factory=dict)
38
+ templates: dict[str, str] = Field(default_factory=dict)
39
+
40
+
41
+ def location(root, value):
42
+ relative(value)
43
+ if any(part.lower() == '.git' for part in Path(value).parts):
44
+ raise ValueError('source files must not modify Git metadata')
45
+ result = root / value
46
+ if not result.resolve().is_relative_to(root.resolve()):
47
+ raise ValueError('path escapes its repository')
48
+ return result
49
+
50
+
51
+ def home():
52
+ return Path(os.environ.get('GENERIC_CI_HOME', Path.home() / '.config/generic-ci'))
53
+
54
+
55
+ def write_json(path, data):
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ tmp = path.with_suffix(path.suffix + '.tmp')
58
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + '\n')
59
+ tmp.replace(path)
60
+
61
+
62
+ def repository(value):
63
+ # Credentials belong to Git's credential helper or SSH agent, never the lock.
64
+ if value.startswith(('https://', 'ssh://')):
65
+ parsed = urlsplit(value)
66
+ if not parsed.hostname or parsed.password or parsed.query or parsed.fragment or (parsed.scheme == 'https' and parsed.username):
67
+ raise ValueError('repository must not contain credentials, query strings or fragments')
68
+ elif re.fullmatch(r'[\w.-]+@[\w.-]+:[^\s]+', value):
69
+ pass
70
+ elif '://' in value or value.startswith('-'):
71
+ raise ValueError('use HTTPS, SSH, or an existing local Git repository/bundle')
72
+ else:
73
+ path = Path(value).expanduser().resolve()
74
+ value = str(path)
75
+ return value
76
+
77
+
78
+ def git(cache, *args, binary=False):
79
+ env = dict(os.environ, GIT_TERMINAL_PROMPT='0')
80
+ result = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', '-C', str(cache), *args], env=env,
81
+ capture_output=True, text=not binary)
82
+ if result.returncode:
83
+ # Git stderr may contain credential-helper output or authenticated URLs.
84
+ raise ValueError('source Git operation failed; check repository access and revision availability')
85
+ return result.stdout if binary else result.stdout.strip()
86
+
87
+
88
+ def cache_for(repo):
89
+ return home() / 'cache' / hashlib.sha256(repo.encode()).hexdigest()
90
+
91
+
92
+ def resolve(source, *, commit=None, offline=False, import_from=None):
93
+ repo = repository(source.repository)
94
+ if not source.ref or source.ref.startswith('-') or any(c.isspace() for c in source.ref):
95
+ raise ValueError('source ref must be a Git branch, tag or commit')
96
+ cache = cache_for(repo)
97
+ if commit and not re.fullmatch('[a-f0-9]{40}', commit):
98
+ raise ValueError('source lock must contain a full Git commit')
99
+ if commit and cache.exists():
100
+ try:
101
+ if git(cache, 'rev-parse', '--verify', commit + '^{commit}') == commit:
102
+ return cache, commit
103
+ except ValueError:
104
+ pass
105
+ if offline and not import_from:
106
+ raise ValueError('source revision is not cached; import a checkout/bundle or fetch it while connected')
107
+ if not cache.exists():
108
+ cache.mkdir(parents=True)
109
+ git(cache, 'init', '--bare')
110
+ origin = repository(import_from) if import_from else repo
111
+ revision = commit or source.ref
112
+ git(cache, 'fetch', '--no-tags', '--', origin, revision)
113
+ resolved = git(cache, 'rev-parse', 'FETCH_HEAD^{commit}')
114
+ if commit and resolved != commit:
115
+ raise ValueError('import does not match the locked source commit')
116
+ return cache, resolved
117
+
118
+
119
+ def blob(cache, commit, path):
120
+ relative(path)
121
+ mode = git(cache, 'ls-tree', commit, '--', path).split()
122
+ if not mode or mode[0] not in {'100644', '100755'}:
123
+ raise ValueError(f'source path must be a regular tracked file: {path}')
124
+ data = git(cache, 'show', commit + ':' + path, binary=True)
125
+ if len(data) > 2_000_000:
126
+ raise ValueError('source file exceeds 2 MB')
127
+ return data
128
+
129
+
130
+ def yaml_blob(cache, commit, path):
131
+ data = yaml.load(blob(cache, commit, path).decode(), Loader=UniqueLoader)
132
+ if not isinstance(data, dict):
133
+ raise ValueError(f'{path}: expected YAML mapping')
134
+ return data
135
+
136
+
137
+ def snapshot(source, cache, commit):
138
+ manifest = Manifest.model_validate(yaml_blob(cache, commit, 'generic-ci-source.yml'))
139
+ if manifest.version != 1 or Version(__version__) not in SpecifierSet(manifest.cli):
140
+ raise ValueError('source manifest version or CLI compatibility requirement is not satisfied')
141
+ if set(manifest.defaults) - {'delivery', 'platform'}:
142
+ raise ValueError('source defaults supports only delivery and platform')
143
+ defaults = {key: yaml_blob(cache, commit, path) for key, path in manifest.defaults.items()}
144
+ return {'version': 1, 'source': source.model_dump(), 'commit': commit,
145
+ 'manifest': manifest.model_dump(), 'defaults': defaults}
146
+
147
+
148
+ def lock_path(root):
149
+ return root / 'generic-ci.lock.json'
150
+
151
+
152
+ def project_config(root):
153
+ return ProjectConfig.model_validate(read_yaml(root / 'generic-ci.yml'))
154
+
155
+
156
+ def locked(root, *, offline=False, import_from=None):
157
+ config = project_config(root)
158
+ path = lock_path(root)
159
+ if not path.exists():
160
+ raise ValueError('source lock is missing; run generic-ci source update')
161
+ lock = json.loads(path.read_text())
162
+ if lock.get('source') != config.source.model_dump() or lock.get('version') != 1:
163
+ raise ValueError('source configuration changed; run generic-ci source update')
164
+ if not re.fullmatch('[a-f0-9]{40}', str(lock.get('commit', ''))):
165
+ raise ValueError('source lock must contain a full Git commit')
166
+ cache, commit = resolve(config.source, commit=lock['commit'], offline=offline, import_from=import_from)
167
+ actual = snapshot(config.source, cache, commit)
168
+ if lock != actual:
169
+ raise ValueError('source lock content differs from its pinned Git revision')
170
+ return config, actual, cache
171
+
172
+
173
+ def apply_layers(layers):
174
+ effective, origins = {}, {}
175
+ def mark(value, prefix, owner):
176
+ if isinstance(value, dict) and value:
177
+ for key, child in value.items():
178
+ mark(child, prefix + ('.' if prefix else '') + key, owner)
179
+ else:
180
+ origins[prefix] = owner
181
+ for owner, data in layers:
182
+ if not isinstance(data, dict):
183
+ raise ValueError('configuration layers must be mappings')
184
+ effective = merge(effective, data)
185
+ mark(data, '', owner)
186
+ # Drop origins for leaves replaced by a scalar/list or vice versa.
187
+ valid = {}
188
+ def retain(value, prefix=''):
189
+ if isinstance(value, dict) and value:
190
+ for key, child in value.items():
191
+ retain(child, prefix + ('.' if prefix else '') + key)
192
+ elif prefix in origins:
193
+ valid[prefix] = origins[prefix]
194
+ retain(effective)
195
+ return effective, valid
196
+
197
+
198
+ def load_project(root, config_override=None, platform_override=None, offline=False):
199
+ config, lock, _ = locked(root, offline=offline)
200
+ delivery = location(root, config_override or config.delivery)
201
+ platform_name = platform_override or config.platform
202
+ delivery_data, dorigins = apply_layers([('source:' + lock['commit'], lock['defaults'].get('delivery', {})),
203
+ ('project:' + delivery.relative_to(root).as_posix(), read_yaml(delivery))])
204
+ platform_layers = [('source:' + lock['commit'], lock['defaults'].get('platform', {}))]
205
+ paths = [root / 'generic-ci.yml', lock_path(root), delivery]
206
+ if platform_name:
207
+ local = location(root, platform_name)
208
+ platform_layers.append(('project:' + platform_name, read_yaml(local)))
209
+ paths.append(local)
210
+ platform_data, porigins = apply_layers(platform_layers)
211
+ return Pipeline.model_validate(delivery_data), Platform.model_validate(platform_data), {'delivery': dorigins, 'platform': porigins}, paths
212
+
213
+
214
+ def initialize(root, template, name=None, repo=None, ref=None, offline=False, delivery=None):
215
+ if repo:
216
+ if not ref:
217
+ raise ValueError('--repo requires --ref')
218
+ source = Source(repository=repository(repo), ref=ref)
219
+ else:
220
+ registry = json.loads((home() / 'sources.json').read_text())
221
+ chosen = name or registry.get('default')
222
+ if chosen not in registry.get('sources', {}):
223
+ raise ValueError('choose a configured source with --source or provide --repo and --ref')
224
+ source = Source.model_validate(registry['sources'][chosen])
225
+ # Registry records the initial pinned revision for offline initialization.
226
+ registry_path = home() / 'pins.json'
227
+ pins = json.loads(registry_path.read_text()) if registry_path.exists() else {}
228
+ pin = pins.get(json.dumps(source.model_dump(), sort_keys=True))
229
+ cache, commit = resolve(source, commit=pin, offline=offline)
230
+ snap = snapshot(source, cache, commit)
231
+ templates = snap['manifest']['templates']
232
+ if not template:
233
+ print('Available templates: ' + ', '.join(sorted(templates)))
234
+ return
235
+ if template not in templates:
236
+ raise ValueError('unknown template; available: ' + ', '.join(sorted(templates)))
237
+ prefix = templates[template].rstrip('/')
238
+ relative(prefix)
239
+ paths = git(cache, 'ls-tree', '-r', '--name-only', commit, '--', prefix).splitlines()
240
+ files = {}
241
+ for path in paths:
242
+ if not path.startswith(prefix + '/'):
243
+ continue
244
+ destination = location(root, path[len(prefix) + 1:])
245
+ files[destination] = blob(cache, commit, path)
246
+ if not files:
247
+ raise ValueError('template contains no files')
248
+ project = ProjectConfig(source=source, delivery=delivery or 'delivery.yml')
249
+ for reserved in [root / 'generic-ci.yml', lock_path(root)]:
250
+ if reserved in files:
251
+ raise ValueError('template cannot supply reserved source/lock files')
252
+ files[root / 'generic-ci.yml'] = yaml.safe_dump(project.model_dump(by_alias=True, exclude_none=True), sort_keys=False).encode()
253
+ files[lock_path(root)] = (json.dumps(snap, indent=2, sort_keys=True) + '\n').encode()
254
+ if location(root, project.delivery) not in files:
255
+ raise ValueError('template does not contain the configured delivery file')
256
+ for path in files:
257
+ if path.exists() or path.is_symlink():
258
+ raise ValueError(f'init will not overwrite {path.relative_to(root)}')
259
+ for path, content in files.items():
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ path.write_bytes(content)
262
+ print(f'Created {template} from source commit {commit}; commit generic-ci.yml and generic-ci.lock.json')
263
+
264
+
265
+ def source_main(argv):
266
+ parser = argparse.ArgumentParser(prog='generic-ci source')
267
+ sub = parser.add_subparsers(dest='action', required=True)
268
+ add = sub.add_parser('add'); add.add_argument('name'); add.add_argument('--repo', required=True); add.add_argument('--ref', required=True); add.add_argument('--default', action='store_true')
269
+ sub.add_parser('list')
270
+ update = sub.add_parser('update'); update.add_argument('--ref'); update.add_argument('--check', action='store_true')
271
+ fetch = sub.add_parser('fetch'); fetch.add_argument('--from', dest='import_from'); fetch.add_argument('--offline', action='store_true')
272
+ for p in [update, fetch]: p.add_argument('--root', default='.')
273
+ args = parser.parse_args(argv)
274
+ registry_path = home() / 'sources.json'
275
+ registry = json.loads(registry_path.read_text()) if registry_path.exists() else {'sources': {}}
276
+ if args.action == 'list':
277
+ print(json.dumps(registry, indent=2)); return 0
278
+ if args.action == 'add':
279
+ source = Source(repository=repository(args.repo), ref=args.ref)
280
+ cache, commit = resolve(source); snapshot(source, cache, commit)
281
+ registry['sources'][args.name] = source.model_dump()
282
+ if args.default: registry['default'] = args.name
283
+ write_json(registry_path, registry)
284
+ pins_path = home() / 'pins.json'
285
+ pins = json.loads(pins_path.read_text()) if pins_path.exists() else {}
286
+ pins[json.dumps(source.model_dump(), sort_keys=True)] = commit
287
+ write_json(pins_path, pins)
288
+ print(f'Registered {args.name} at {commit}'); return 0
289
+ root = Path(args.root).resolve()
290
+ if args.action == 'fetch':
291
+ _, snap, _ = locked(root, offline=args.offline, import_from=args.import_from)
292
+ print('Cached ' + snap['commit']); return 0
293
+ config = project_config(root)
294
+ if args.ref: config.source.ref = args.ref
295
+ cache, commit = resolve(config.source)
296
+ updated = snapshot(config.source, cache, commit)
297
+ old = json.loads(lock_path(root).read_text()) if lock_path(root).exists() else {}
298
+ import difflib
299
+ before = json.dumps(old.get('defaults', {}), indent=2, sort_keys=True).splitlines(True)
300
+ after = json.dumps(updated['defaults'], indent=2, sort_keys=True).splitlines(True)
301
+ print('Source revision: ' + old.get('commit', '(none)') + ' -> ' + commit)
302
+ print(''.join(difflib.unified_diff(before, after, fromfile='old defaults', tofile='new defaults')), end='')
303
+ # Check the complete effective configuration before recording the update.
304
+ delivery = read_yaml(location(root, config.delivery))
305
+ effective = Pipeline.model_validate(merge(updated['defaults'].get('delivery', {}), delivery))
306
+ platform = updated['defaults'].get('platform', {})
307
+ if config.platform: platform = merge(platform, read_yaml(location(root, config.platform)))
308
+ from .workflows.compiler import compile_pipeline
309
+ compile_pipeline(effective, Platform.model_validate(platform))
310
+ if args.check: return 0 if old == updated else 1
311
+ write_json(lock_path(root), updated)
312
+ (root / 'generic-ci.yml').write_text(yaml.safe_dump(config.model_dump(by_alias=True, exclude_none=True), sort_keys=False))
313
+ print('Updated source lock; review the diff and regenerate CI. Template files were preserved.')
314
+ return 0
@@ -0,0 +1 @@
1
+ """Developer-defined workflows, revision one."""
@@ -0,0 +1,316 @@
1
+ """Compile event workflows into explicit jobs and verifiable artifact dependencies."""
2
+ import base64
3
+ import copy
4
+ import json
5
+ import re
6
+ import yaml
7
+ from generic_ci import __version__
8
+ from generic_ci.compiler import digest, variants
9
+ from generic_ci.config import read_yaml, allowed_url
10
+ from .models import Pipeline, Platform
11
+
12
+
13
+ def merge(*values):
14
+ result = {}
15
+ for value in values:
16
+ for key, item in value.items():
17
+ result[key] = merge(result.get(key, {}), item) if isinstance(item, dict) and isinstance(result.get(key, {}), dict) else copy.deepcopy(item)
18
+ return result
19
+
20
+
21
+ def load(config, platform):
22
+ return Pipeline.model_validate(read_yaml(config)), Platform.model_validate(read_yaml(platform))
23
+
24
+
25
+ def tag_pattern(tag):
26
+ return '^' + re.escape(tag).replace(re.escape('{version}'), '.+') .replace('/', r'\/') + '$'
27
+
28
+
29
+ def event_rule(event, project=None):
30
+ if event == 'release':
31
+ return '$CI_COMMIT_TAG =~ /' + tag_pattern(project['release']['tag']) + '/'
32
+ return {'push': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH',
33
+ 'merge-request': '$CI_PIPELINE_SOURCE == "merge_request_event"',
34
+ 'manual': '$CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH',
35
+ 'schedule': '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_BRANCH'}[event]
36
+
37
+
38
+ def compile_pipeline(pipeline, platform, sources=None):
39
+ p, infra = pipeline.model_dump(), platform.model_dump()
40
+ projects, nodes, refs = p['projects'], {}, {}
41
+ # Defaults on models describe the schema, not explicit override requests.
42
+ for name, project in projects.items():
43
+ source = pipeline.projects[name]
44
+ project['defaults'] = source.defaults.model_dump(exclude_unset=True)
45
+ for key, settings in project['checks'].items():
46
+ explicit = source.checks[key].model_dump(exclude_unset=True)
47
+ for field in ('image', 'tags', 'timeout', 'variables', 'services', 'parallel', 'artifacts'):
48
+ if field not in explicit:
49
+ settings.pop(field, None)
50
+ if isinstance(settings['dependencies'], dict):
51
+ settings['dependencies'] = source.checks[key].dependencies.model_dump(exclude_unset=True)
52
+ hosts = infra['allowed_hosts']
53
+ for value in [*infra['images'].values(), infra['container_builder']['image'], *infra['registries'].values()]:
54
+ allowed_url('https://' + value.split('/')[0], hosts)
55
+ if infra['registries']['containers'] == infra['registries']['previews']:
56
+ raise ValueError('release and preview registries must be distinct repositories')
57
+ for name, project in projects.items():
58
+ for dep in project['depends_on']:
59
+ if dep not in projects or dep == name:
60
+ raise ValueError(f'{name}.depends-on: unknown/self project {dep}')
61
+ if project['container'] and project['container']['repository']:
62
+ allowed_url('https://' + project['container']['repository'].split('/')[0], hosts)
63
+ def project_visit(name, stack):
64
+ if name in stack:
65
+ raise ValueError('project dependency cycle: ' + ' -> '.join([*stack, name]))
66
+ for dep in projects[name]['depends_on']:
67
+ project_visit(dep, [*stack, name])
68
+ for name in projects:
69
+ project_visit(name, [])
70
+
71
+ def add(key, project, event, action, settings, needs=(), execution=None, condition=None, deployment=None):
72
+ candidate = dict(project=project, event=event, action=action, settings=settings,
73
+ needs=list(needs), execution=execution or {}, condition=condition,
74
+ deployment=deployment)
75
+ if key in nodes and nodes[key] != candidate:
76
+ raise ValueError(f'generated job name collision: {key}; rename the user check or project')
77
+ nodes[key] = candidate
78
+ return key
79
+
80
+ def check(name, key, event, context=None):
81
+ project = projects[name]
82
+ if key not in project['checks']:
83
+ raise ValueError(f'{name}: unknown check {key}; available: {list(project["checks"])}')
84
+ settings = project['checks'][key]
85
+ execution = merge(infra['defaults'], project['defaults'], {k: v for k, v in settings.items() if v is not None})
86
+ if settings['dependencies'] is not None and settings['dependencies'] is not False and project['python'] is None:
87
+ raise ValueError(f'{name}.{key}: dependency group overrides require Python')
88
+ rows = variants(execution)
89
+ if settings['outputs'] and len(rows) != 1:
90
+ raise ValueError(f'{name}.{key}: matrix output paths would collide')
91
+ result = []
92
+ for index, row in enumerate(rows):
93
+ identifier = ':'.join(filter(None, [event, context, name, key, str(index + 1) if len(rows) > 1 else None]))
94
+ ex = merge(execution, {'variables': row})
95
+ result.append(add(identifier, name, event, 'check', settings, execution=ex, deployment=context))
96
+ if not context:
97
+ refs[event, name + '.' + key] = result
98
+ return result
99
+
100
+ for name, project in projects.items():
101
+ for event, workflow in project['workflows'].items():
102
+ if event == 'release' and not project['release']:
103
+ raise ValueError(f'{name}: release workflow requires release metadata')
104
+ gates = [job for key in workflow['checks'] for job in check(name, key, event)]
105
+ if project['release']:
106
+ vjob = add(f'{event}:{name}:version', name, event, 'version', project['release'])
107
+ gates.append(vjob)
108
+ operations = workflow['build']
109
+ available = {'application': project['build'], 'container': project['container'], 'package': project['package']}
110
+ if operations is True:
111
+ operations = [kind for kind, setting in available.items() if setting is not None]
112
+ if len(operations) != 1:
113
+ raise ValueError(f'{name}: build: true requires exactly one output; list application/container/package explicitly')
114
+ elif operations is False:
115
+ operations = []
116
+ publication = workflow['publish']
117
+ channel = publication['channel'] if isinstance(publication, dict) else 'auto'
118
+ if publication:
119
+ if channel == 'development':
120
+ if event == 'release':
121
+ raise ValueError(f'{name}: development publication cannot run on release tags')
122
+ elif event != 'release':
123
+ raise ValueError(f'{name}: auto publication requires a release workflow')
124
+ if 'package' not in operations:
125
+ operations = [*operations, 'package']
126
+ built = []
127
+ for kind in operations:
128
+ if available[kind] is None:
129
+ raise ValueError(f'{name}: no {kind} build configured')
130
+ public = {'application': 'build', 'container': 'build-image', 'package': 'build-package'}[kind]
131
+ key = add(f'{event}:{name}:{public}', name, event, kind, available[kind], gates)
132
+ if kind == 'package':
133
+ nodes[key]['settings'] = {**nodes[key]['settings'], 'channel': channel if publication else 'auto'}
134
+ nodes[key]['execution'] = merge(infra['defaults'], project['defaults'])
135
+ refs[event, name + '.' + public] = [key]
136
+ built.append(key)
137
+ if event == 'release' or publication:
138
+ final = add(f'{event}:{name}:publish', name, event, 'publish', {'publish_package': bool(publication), 'channel': channel}, gates + built)
139
+ refs[event, name + '.publish'] = [final]
140
+ release = project['release']
141
+ if release and release['require_bump'] and 'merge-request' not in project['workflows']:
142
+ add(f'merge-request:{name}:version', name, 'merge-request', 'version', release)
143
+ if release and release['create']:
144
+ if 'push' not in project['workflows'] or 'release' not in project['workflows']:
145
+ raise ValueError(f'{name}: release button requires push and release workflows')
146
+ gates = [k for k, n in nodes.items() if n['project'] == name and n['event'] == 'push']
147
+ add(f'push:{name}:create-release', name, 'push', 'create-release', release, gates,
148
+ condition='$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ' + json.dumps(release['create']['branch']))
149
+
150
+ # Explicit artifact edges never substitute packages. Producers must be selected in that event.
151
+ for key, node in list(nodes.items()):
152
+ if node['action'] not in {'check', 'application', 'container', 'package', 'publish'}:
153
+ continue
154
+ requested = node['settings'].get('needs', [])
155
+ if node['action'] == 'publish' and node['event'] == 'release':
156
+ requested = [dep + '.publish' for dep in projects[node['project']]['release']['needs']]
157
+ for ref in requested:
158
+ ref = ref if '.' in ref else node['project'] + '.' + ref
159
+ if (node['event'], ref) not in refs:
160
+ raise ValueError(f'{key}: producer {ref} is not selected in {node["event"]}')
161
+ node['needs'] += refs[node['event'], ref]
162
+ if node['event'] == 'release':
163
+ dep = ref.split('.')[0]
164
+ if projects[dep]['release']['tag'] != projects[node['project']]['release']['tag']:
165
+ raise ValueError('release artifact/publication dependencies require a coordinated shared release tag')
166
+
167
+ for name, deployment in p['deployments'].items():
168
+ if deployment['target'] not in infra['targets']:
169
+ raise ValueError(f'{name}: unknown target {deployment["target"]}')
170
+ chart = deployment['chart']
171
+ for source in ('repository', 'oci'):
172
+ if chart[source]:
173
+ allowed_url(chart[source], hosts)
174
+ paths = [v for binding in deployment['images'] for v in binding['set'].values() if v]
175
+ if any(a == b or a.startswith(b + '.') or b.startswith(a + '.') for i, a in enumerate(paths) for b in paths[i+1:]):
176
+ raise ValueError(f'{name}: overlapping image mappings')
177
+ for event, behavior in deployment['workflows'].items():
178
+ if event == 'release' and deployment['update'] == 'complete':
179
+ tags = {projects[b['from_'].split('.')[0]]['release']['tag']
180
+ for b in deployment['images']
181
+ if b['from_'].split('.')[0] in projects and projects[b['from_'].split('.')[0]]['release']}
182
+ if len(tags) > 1:
183
+ raise ValueError(f'{name}: complete release deployment requires a coordinated shared release tag; use partial for independent tags')
184
+ producers = []
185
+ conditions = []
186
+ for binding in deployment['images']:
187
+ ref = binding['from_']
188
+ if (event, ref) not in refs or not ref.endswith('.build-image'):
189
+ raise ValueError(f'{name}: image producer {ref} must build in {event}')
190
+ producers += refs[event, ref]
191
+ conditions.append(event_rule(event, projects[ref.split('.')[0]]))
192
+ condition = ' || '.join('(' + c + ')' for c in dict.fromkeys(conditions))
193
+ if event == 'merge-request':
194
+ condition = '(' + condition + ') && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID'
195
+ key = f'{event}:deploy:{name}'
196
+ prior = producers[:]
197
+ if behavior['when'] == 'manual':
198
+ approval = add(f'{event}:approve:{name}', None, event, 'approve', deployment, producers, condition=condition, deployment=name)
199
+ prior.append(approval)
200
+ before_dependencies = prior[:]
201
+ for ref in deployment['before']['checks']:
202
+ owner, ck = ref.split('.', 1)
203
+ if owner not in projects:
204
+ raise ValueError(f'unknown check owner {owner}')
205
+ for job in check(owner, ck, event, name + '-before'):
206
+ nodes[job]['needs'] = before_dependencies[:]
207
+ nodes[job]['condition'] = condition
208
+ nodes[job]['deployment'] = name
209
+ prior.append(job)
210
+ add(key, None, event, 'deploy', deployment, prior, condition=condition, deployment=name)
211
+ for ref in deployment['after']['checks']:
212
+ owner, ck = ref.split('.', 1)
213
+ if owner not in projects:
214
+ raise ValueError(f'unknown check owner {owner}')
215
+ for job in check(owner, ck, event, name + '-after'):
216
+ nodes[job]['needs'] = [key]
217
+ nodes[job]['condition'] = condition
218
+ nodes[job]['deployment'] = name
219
+ if event == 'merge-request':
220
+ add(f'{event}:stop:{name}', None, event, 'stop', deployment, condition=condition, deployment=name)
221
+
222
+ # Contextual deployment checks retain the same explicit artifact contract.
223
+ for key, node in nodes.items():
224
+ if node['action'] == 'check' and node['deployment']:
225
+ for ref in node['settings'].get('needs', []):
226
+ ref = ref if '.' in ref else node['project'] + '.' + ref
227
+ if (node['event'], ref) not in refs:
228
+ raise ValueError(f'{key}: producer {ref} is not selected in {node["event"]}')
229
+ node['needs'] += refs[node['event'], ref]
230
+
231
+ visiting, visited = set(), set()
232
+ def visit(key):
233
+ if key in visiting:
234
+ raise ValueError(f'job dependency cycle: {key}')
235
+ if key in visited:
236
+ return
237
+ visiting.add(key)
238
+ for dep in nodes[key]['needs']:
239
+ visit(dep)
240
+ visiting.remove(key)
241
+ visited.add(key)
242
+ for key in nodes:
243
+ visit(key)
244
+ if len(nodes) + 1 > infra['max_jobs']:
245
+ raise ValueError('generated job count exceeds platform max-jobs')
246
+ payload = {'version': __version__, 'format': 'workflows-v1', 'pipeline': p, 'platform': infra, 'nodes': nodes, 'sources': sources or {}}
247
+ fingerprint = digest(payload)
248
+ encoded = base64.b64encode(json.dumps(payload, sort_keys=True).encode()).decode()
249
+ if len(encoded) > 100000:
250
+ raise ValueError('execution configuration exceeds 100 KB; split project groups')
251
+ control = infra['images'].get('control', infra['images'].get('python'))
252
+ if not control:
253
+ raise ValueError('platform images.control or images.python is required for the planner')
254
+ output = {'stages': ['prepare', 'delivery'], 'workflow': {'rules': [
255
+ {'if': '$CI_PIPELINE_SOURCE == "merge_request_event"'},
256
+ {'if': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_TAG'},
257
+ {'if': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS', 'when': 'never'},
258
+ {'if': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH'},
259
+ {'if': '$CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH'},
260
+ {'if': '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_BRANCH'}]},
261
+ 'variables': {**infra['variables'], 'TOOLKIT_CONFIG_B64': encoded},
262
+ 'toolkit-plan': {'stage': 'prepare', 'image': control, 'tags': infra['defaults']['tags'] or [],
263
+ 'script': [f'python -m generic_ci.workflows.runtime plan {fingerprint}'],
264
+ 'artifacts': {'paths': ['.ci-out/plan.json']}}}
265
+ for key, node in nodes.items():
266
+ project = projects.get(node['project'], {})
267
+ role = 'bun' if (project.get('node') or {}).get('package_manager') == 'bun' else 'node' if project.get('node') else 'python'
268
+ if node['action'] in {'deploy', 'stop'}:
269
+ role = 'helm'
270
+ if node['action'] in {'create-release', 'version', 'publish', 'approve'}:
271
+ role = 'control' if node['action'] != 'publish' else role
272
+ ex = node['execution']
273
+ if not ex.get('image') and role not in infra['images'] and role != 'control' and node['action'] != 'container':
274
+ raise ValueError(f'platform images.{role} is required for {key}')
275
+ image = ex.get('image') or infra['images'].get(role, control)
276
+ tags = ex.get('tags') if ex.get('tags') is not None else infra['defaults']['tags'] or []
277
+ if node['action'] == 'container':
278
+ image = infra['container_builder']['image']
279
+ tags = infra['container_builder']['tags'] or tags
280
+ allowed_url('https://' + image.split('/')[0], hosts)
281
+ for service in ex.get('services') or []:
282
+ allowed_url('https://' + service.split('/')[0], hosts)
283
+ if len(node['needs']) >= 50:
284
+ raise ValueError(f'{key}: exceeds supported needs limit')
285
+ condition = node['condition'] or event_rule(node['event'], project)
286
+ rule = {'if': condition}
287
+ if node['action'] in {'create-release', 'stop', 'approve'}:
288
+ rule.update(when='manual', allow_failure=node['action'] == 'stop')
289
+ job = {'stage': 'delivery', 'image': image, 'tags': tags, 'rules': [rule],
290
+ 'script': [f'python -m generic_ci.workflows.runtime run {key} {fingerprint}'],
291
+ 'needs': [{'job': 'toolkit-plan', 'artifacts': True}] + [{'job': d, 'artifacts': True, 'optional': True} for d in sorted(set(node['needs']))],
292
+ 'artifacts': merge(ex.get('artifacts', {}), {'when': 'always'}), 'variables': ex.get('variables', {}), 'retry': 0}
293
+ job['artifacts']['paths'] = list(dict.fromkeys(job['artifacts'].get('paths', []) + [f'.ci-out/{key}/']))
294
+ for field in ('timeout', 'services'):
295
+ if ex.get(field) is not None:
296
+ job[field] = ex[field]
297
+ if node['action'] in {'deploy', 'stop'}:
298
+ target = infra['targets'][node['settings']['target']]
299
+ release = target['release'] or node['deployment']
300
+ suffix = '/mr-$CI_MERGE_REQUEST_IID' if node['event'] == 'merge-request' else ''
301
+ env = node['settings']['target'] + '/' + release + suffix
302
+ job['resource_group'] = env
303
+ job['environment'] = {'name': env, 'url': target['url']}
304
+ if node['event'] == 'merge-request':
305
+ job['environment'].update(on_stop=f'merge-request:stop:{node["deployment"]}', auto_stop_in=node['settings']['auto_stop_in'])
306
+ if node['action'] == 'stop':
307
+ job['environment'] = {'name': env, 'action': 'stop'}
308
+ job['needs'] = []
309
+ job['variables'] = {'GIT_STRATEGY': 'none'}
310
+ if node['action'] in {'create-release', 'publish'}:
311
+ job['resource_group'] = 'release/' + node['project']
312
+ output[key] = job
313
+ for key in ['toolkit-plan', *nodes]:
314
+ if not output[key].get('tags'):
315
+ output[key].pop('tags', None)
316
+ return output, payload