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,421 @@
|
|
|
1
|
+
"""Execution of revision-one workflows using installed internal tools."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import tomllib
|
|
12
|
+
import tarfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from email.parser import BytesParser
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from generic_ci.runtime import (load_config, identity, write_json, path_in, require_receipt,
|
|
19
|
+
receipt_path, collect, materialize, commands)
|
|
20
|
+
from generic_ci.dependencies import resolve_candidates
|
|
21
|
+
from generic_ci.config import allowed_url
|
|
22
|
+
from .ecosystems import environment, read_version, extract_version, version_key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def git(root, *args):
|
|
26
|
+
return subprocess.check_output(['git', *args], cwd=root, text=True).strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def matches(tag, release):
|
|
30
|
+
return bool(release and re.fullmatch(re.escape(release['tag']).replace(re.escape('{version}'), '.+'), tag))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_plan(data, root, expected):
|
|
34
|
+
for path, sha in data['sources'].items():
|
|
35
|
+
if hashlib.sha256(path_in(root, path).read_bytes()).hexdigest() != sha:
|
|
36
|
+
raise ValueError(f'{path} changed; render and commit the pipeline again')
|
|
37
|
+
projects = data['pipeline']['projects']
|
|
38
|
+
source, tag = os.environ.get('CI_PIPELINE_SOURCE'), os.environ.get('CI_COMMIT_TAG', '')
|
|
39
|
+
event = 'release' if tag else {'merge_request_event': 'merge-request', 'push': 'push', 'web': 'manual', 'schedule': 'schedule'}.get(source)
|
|
40
|
+
if event is None:
|
|
41
|
+
raise ValueError('unsupported workflow event')
|
|
42
|
+
changed = None
|
|
43
|
+
base = os.environ.get('CI_MERGE_REQUEST_DIFF_BASE_SHA') or os.environ.get('CI_COMMIT_BEFORE_SHA', '')
|
|
44
|
+
if event in {'push', 'merge-request'} and re.fullmatch('[a-f0-9]{40}', base) and set(base) != {'0'}:
|
|
45
|
+
try:
|
|
46
|
+
changed = git(root, 'diff', '--name-only', '--no-renames', base, 'HEAD').splitlines()
|
|
47
|
+
except subprocess.CalledProcessError:
|
|
48
|
+
pass # Missing shallow baseline conservatively selects everything.
|
|
49
|
+
selected = set(projects)
|
|
50
|
+
if changed is not None and not set(changed).intersection(data['sources']):
|
|
51
|
+
import fnmatch
|
|
52
|
+
selected = {name for name, p in projects.items() if any(
|
|
53
|
+
p['path'] == '.' or f == p['path'] or f.startswith(p['path'].rstrip('/') + '/') or
|
|
54
|
+
any(fnmatch.fnmatch(f, pat) for pat in p['watch']) or f in {'uv.lock', 'package-lock.json', 'pnpm-lock.yaml', 'bun.lock', 'pyproject.toml', 'package.json', 'pnpm-workspace.yaml'}
|
|
55
|
+
for f in changed)}
|
|
56
|
+
direct = set(selected)
|
|
57
|
+
if event == 'release':
|
|
58
|
+
selected = {name for name, p in projects.items() if matches(tag, p['release'])}
|
|
59
|
+
direct = set(selected)
|
|
60
|
+
if not selected:
|
|
61
|
+
raise ValueError('tag matches no project release')
|
|
62
|
+
else:
|
|
63
|
+
while True:
|
|
64
|
+
more = selected | {name for name, p in projects.items() if set(p['depends_on']) & selected}
|
|
65
|
+
if more == selected:
|
|
66
|
+
break
|
|
67
|
+
selected = more
|
|
68
|
+
candidates = resolve_candidates(os.environ, data['platform']['allowed_hosts'], projects)
|
|
69
|
+
if candidates and event == 'release':
|
|
70
|
+
raise ValueError('candidate overrides are forbidden in release workflows')
|
|
71
|
+
for c in candidates:
|
|
72
|
+
selected.update(c['projects'])
|
|
73
|
+
# Close over active deployment checks as well as ordinary project jobs.
|
|
74
|
+
# Overlapping deployments can activate one another, regardless of declaration order.
|
|
75
|
+
while True:
|
|
76
|
+
more = set(selected)
|
|
77
|
+
active_deployments = set()
|
|
78
|
+
for name, deployment in data['pipeline']['deployments'].items():
|
|
79
|
+
owners = {binding['from_'].split('.')[0] for binding in deployment['images']}
|
|
80
|
+
if event in deployment['workflows'] and owners & selected:
|
|
81
|
+
active_deployments.add(name)
|
|
82
|
+
if event == 'merge-request' or deployment['update'] == 'complete':
|
|
83
|
+
more.update(owners)
|
|
84
|
+
for node in data['nodes'].values():
|
|
85
|
+
active = node['deployment'] in active_deployments if node['deployment'] else node['project'] in selected
|
|
86
|
+
if node['event'] != event or not active:
|
|
87
|
+
continue
|
|
88
|
+
for dep in node['needs']:
|
|
89
|
+
producer = data['nodes'][dep]
|
|
90
|
+
deployment = data['pipeline']['deployments'].get(node['deployment'])
|
|
91
|
+
if deployment and event == 'release' and deployment['update'] == 'partial' and producer['action'] == 'container':
|
|
92
|
+
continue
|
|
93
|
+
if producer['project'] is not None:
|
|
94
|
+
more.add(producer['project'])
|
|
95
|
+
if more == selected:
|
|
96
|
+
break
|
|
97
|
+
selected = more
|
|
98
|
+
plan = {**identity(expected), 'event': event, 'selected': sorted(selected), 'direct': sorted(direct), 'candidates': candidates}
|
|
99
|
+
write_json(root / '.ci-out/plan.json', plan)
|
|
100
|
+
print(json.dumps(plan, indent=2))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def api(data, method, path, payload=None, missing=False):
|
|
104
|
+
base = os.environ['CI_API_V4_URL']
|
|
105
|
+
allowed_url(base, data['platform']['allowed_hosts'])
|
|
106
|
+
url = base.rstrip('/') + '/projects/' + urllib.parse.quote(os.environ['CI_PROJECT_ID'], safe='') + path
|
|
107
|
+
token = os.environ.get('TOOLKIT_RELEASE_TOKEN')
|
|
108
|
+
if not token:
|
|
109
|
+
raise ValueError('TOOLKIT_RELEASE_TOKEN must be a protected project access token with API access')
|
|
110
|
+
request = urllib.request.Request(url, method=method, headers={'PRIVATE-TOKEN': token, 'Content-Type': 'application/json'},
|
|
111
|
+
data=json.dumps(payload).encode() if payload is not None else None)
|
|
112
|
+
try:
|
|
113
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
114
|
+
return json.load(response)
|
|
115
|
+
except urllib.error.HTTPError as error:
|
|
116
|
+
if missing and error.code == 404:
|
|
117
|
+
return None
|
|
118
|
+
raise ValueError(f'GitLab API {method} failed with HTTP {error.code}') from None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_version(project, root, plan):
|
|
122
|
+
release = project['release']
|
|
123
|
+
directory = path_in(root, project['path'])
|
|
124
|
+
current = read_version(directory, release)
|
|
125
|
+
is_node = release['version']['file'].endswith('.json')
|
|
126
|
+
tag = release['tag'].format(version=current)
|
|
127
|
+
if plan['event'] == 'release':
|
|
128
|
+
if tag != os.environ['CI_COMMIT_TAG']:
|
|
129
|
+
raise ValueError(f'release tag must be {tag}')
|
|
130
|
+
return current
|
|
131
|
+
target = os.environ.get('CI_MERGE_REQUEST_TARGET_BRANCH_NAME')
|
|
132
|
+
if target and release['require_bump']:
|
|
133
|
+
subprocess.run(['git', 'fetch', '--no-tags', 'origin', f'+refs/heads/{target}:refs/ci/version-base'], cwd=root, check=True)
|
|
134
|
+
file = (Path(project['path']) / release['version']['file']).as_posix()
|
|
135
|
+
result = subprocess.run(['git', 'show', 'refs/ci/version-base:' + file], cwd=root, text=True, capture_output=True)
|
|
136
|
+
if result.returncode:
|
|
137
|
+
# New file is legitimate only when absent from a successfully fetched tree.
|
|
138
|
+
exists = git(root, 'ls-tree', 'refs/ci/version-base', '--', file)
|
|
139
|
+
if exists:
|
|
140
|
+
raise ValueError('cannot read target-branch version')
|
|
141
|
+
else:
|
|
142
|
+
previous = extract_version(result.stdout, release['version'])
|
|
143
|
+
if version_key(current, is_node) <= version_key(previous, is_node):
|
|
144
|
+
raise ValueError(f'bump {file}: {current} must be greater than target version {previous}')
|
|
145
|
+
subprocess.run(['git', 'fetch', '--tags', 'origin'], cwd=root, check=True)
|
|
146
|
+
for existing in git(root, 'tag', '--list').splitlines():
|
|
147
|
+
match = re.fullmatch(re.escape(release['tag']).replace(re.escape('{version}'), '(.+)'), existing)
|
|
148
|
+
if match:
|
|
149
|
+
try:
|
|
150
|
+
old = version_key(match[1], is_node)
|
|
151
|
+
except ValueError:
|
|
152
|
+
continue
|
|
153
|
+
if version_key(current, is_node) <= old:
|
|
154
|
+
raise ValueError(f'version {current} must exceed released version {match[1]}')
|
|
155
|
+
return current
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_release(data, project, root):
|
|
159
|
+
release = project['release']
|
|
160
|
+
if os.environ.get('CI_COMMIT_REF_PROTECTED') != 'true' or os.environ.get('CI_COMMIT_BRANCH') != release['create']['branch']:
|
|
161
|
+
raise ValueError('release creation requires the configured protected branch')
|
|
162
|
+
current = read_version(path_in(root, project['path']), release)
|
|
163
|
+
tag = release['tag'].format(version=current)
|
|
164
|
+
ref = '/repository/tags/' + urllib.parse.quote(tag, safe='')
|
|
165
|
+
existing = api(data, 'GET', ref, missing=True)
|
|
166
|
+
if existing:
|
|
167
|
+
if existing['commit']['id'] != os.environ['CI_COMMIT_SHA']:
|
|
168
|
+
raise ValueError('release tag already points to another commit; it will not be moved')
|
|
169
|
+
return {'tag': tag, 'already_exists': True}
|
|
170
|
+
validate_version(project, root, {'event': 'push'})
|
|
171
|
+
api(data, 'POST', '/repository/tags', {'tag_name': tag, 'ref': os.environ['CI_COMMIT_SHA']})
|
|
172
|
+
return {'tag': tag, 'commit': os.environ['CI_COMMIT_SHA']}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_image(data, node, root, out, plan):
|
|
176
|
+
project = data['pipeline']['projects'][node['project']]
|
|
177
|
+
settings = node['settings']
|
|
178
|
+
candidates = [c for c in plan['candidates'] if node['project'] in c['projects']]
|
|
179
|
+
if candidates and (not settings['dependency_bundle'] or project['python'] is None):
|
|
180
|
+
raise ValueError('candidate image builds require the Python dependency-bundle contract; Node candidate images are not supported yet')
|
|
181
|
+
release = plan['event'] == 'release'
|
|
182
|
+
if release and os.environ.get('CI_COMMIT_REF_PROTECTED') != 'true':
|
|
183
|
+
raise ValueError('release image push requires protected tag')
|
|
184
|
+
repository = settings['repository'] or data['platform']['registries']['containers' if release else 'previews'].rstrip('/') + '/' + node['project']
|
|
185
|
+
if not release and settings['repository']:
|
|
186
|
+
repository = data['platform']['registries']['previews'].rstrip('/') + '/' + node['project']
|
|
187
|
+
allowed_url('https://' + repository.split('/')[0], data['platform']['allowed_hosts'])
|
|
188
|
+
version = read_version(path_in(root, project['path']), project['release']) if project['release'] else None
|
|
189
|
+
tag = version if release else 'ci-' + os.environ['CI_PIPELINE_ID'] + '-' + os.environ['CI_COMMIT_SHA'][:12]
|
|
190
|
+
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
|
|
191
|
+
raise ValueError('version is not a valid container tag; configure a compatible version')
|
|
192
|
+
directory = path_in(root, project['path'])
|
|
193
|
+
image = repository + ':' + tag
|
|
194
|
+
args = ['buildah', 'build', '--iidfile', str(out / 'iid'), '--tag', image,
|
|
195
|
+
'--file', str(path_in(directory, settings['dockerfile']))]
|
|
196
|
+
for key, value in settings['build_args'].items():
|
|
197
|
+
args += ['--build-arg', key + '=' + value]
|
|
198
|
+
for key, variable in settings['secrets'].items():
|
|
199
|
+
if not re.fullmatch(r'[A-Za-z0-9_-]+', key):
|
|
200
|
+
raise ValueError('invalid build secret identifier')
|
|
201
|
+
source = Path(os.environ[variable])
|
|
202
|
+
if not source.is_file() or ',' in str(source):
|
|
203
|
+
raise ValueError('build secret must reference a file-type variable')
|
|
204
|
+
args += ['--secret', f'id={key},src={source}']
|
|
205
|
+
if settings['target']:
|
|
206
|
+
args += ['--target', settings['target']]
|
|
207
|
+
if settings['dependency_bundle']:
|
|
208
|
+
if project['python'] is None:
|
|
209
|
+
raise ValueError('dependency-bundle currently supports Python wheelhouses')
|
|
210
|
+
from generic_ci.runtime import bundle_dependencies
|
|
211
|
+
bundle = out / 'dependency-bundle'; bundle.mkdir(exist_ok=True)
|
|
212
|
+
with environment(project, {}, root, node['project'], plan['candidates'], data['platform']['allowed_hosts'], out) as resolved:
|
|
213
|
+
bundle_dependencies(resolved, directory, bundle, data['platform']['allowed_hosts'])
|
|
214
|
+
args += ['--build-context', 'ci-dependencies=' + str(bundle)]
|
|
215
|
+
args += [str(path_in(directory, settings['context']))]
|
|
216
|
+
try:
|
|
217
|
+
subprocess.run(args, cwd=root, check=True)
|
|
218
|
+
subprocess.run(['buildah', 'push', '--digestfile', str(out / 'digest'), image, 'docker://' + image], cwd=root, check=True)
|
|
219
|
+
value = (out / 'digest').read_text().strip()
|
|
220
|
+
if not re.fullmatch('sha256:[a-f0-9]{64}', value):
|
|
221
|
+
raise ValueError('Buildah push did not return a registry digest')
|
|
222
|
+
return {'image': {'repository': repository, 'tag': tag, 'digest': value, 'version': version, 'pushed': True}}
|
|
223
|
+
finally:
|
|
224
|
+
if (out / 'iid').is_file():
|
|
225
|
+
subprocess.run(['buildah', 'rmi', (out / 'iid').read_text().strip()], capture_output=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def package_build(project, root, out, directory=None):
|
|
229
|
+
directory = directory or path_in(path_in(root, project['path']), project['package']['directory'])
|
|
230
|
+
dist = out / 'dist'
|
|
231
|
+
dist.mkdir(exist_ok=True)
|
|
232
|
+
if project['python'] is not None:
|
|
233
|
+
subprocess.run(['uv', 'build', '--project', str(directory), '--no-sources', '--out-dir', str(dist)], cwd=directory, check=True)
|
|
234
|
+
files = list(dist.glob('*.whl')) + list(dist.glob('*.tar.gz'))
|
|
235
|
+
if not list(dist.glob('*.whl')) or not list(dist.glob('*.tar.gz')):
|
|
236
|
+
raise ValueError('package build must produce wheel and sdist')
|
|
237
|
+
subprocess.run([sys.executable, '-m', 'twine', 'check', *map(str, files)], check=True)
|
|
238
|
+
elif project['node'] is not None:
|
|
239
|
+
# npm pack handles npm-compatible output regardless of installation manager.
|
|
240
|
+
subprocess.run(['npm', 'pack', '--pack-destination', str(dist)], cwd=directory, check=True)
|
|
241
|
+
files = list(dist.glob('*.tgz'))
|
|
242
|
+
if len(files) != 1:
|
|
243
|
+
raise ValueError('npm pack must produce exactly one package')
|
|
244
|
+
else:
|
|
245
|
+
raise ValueError('package build requires Python or Node settings')
|
|
246
|
+
# Validate the actual archive metadata, not just the source manifest.
|
|
247
|
+
expected_meta = tomllib.loads((directory / 'pyproject.toml').read_text())['project'] if project['python'] is not None else json.loads((directory / 'package.json').read_text())
|
|
248
|
+
for file in files:
|
|
249
|
+
if file.suffix == '.whl':
|
|
250
|
+
with zipfile.ZipFile(file) as archive:
|
|
251
|
+
members = [n for n in archive.namelist() if n.endswith('.dist-info/METADATA')]
|
|
252
|
+
if len(members) != 1:
|
|
253
|
+
raise ValueError('wheel requires exactly one METADATA file')
|
|
254
|
+
metadata = BytesParser().parsebytes(archive.read(members[0]))
|
|
255
|
+
built_name, built_version = metadata['Name'], metadata['Version']
|
|
256
|
+
else:
|
|
257
|
+
with tarfile.open(file) as archive:
|
|
258
|
+
if project['node'] is not None:
|
|
259
|
+
metadata = json.load(archive.extractfile('package/package.json'))
|
|
260
|
+
built_name, built_version = metadata['name'], metadata['version']
|
|
261
|
+
else:
|
|
262
|
+
members = [m for m in archive.getmembers() if len(Path(m.name).parts) == 2 and m.name.endswith('/PKG-INFO')]
|
|
263
|
+
if len(members) != 1:
|
|
264
|
+
raise ValueError('sdist requires root PKG-INFO')
|
|
265
|
+
metadata = BytesParser().parsebytes(archive.extractfile(members[0]).read())
|
|
266
|
+
built_name, built_version = metadata['Name'], metadata['Version']
|
|
267
|
+
normalize = lambda s: re.sub(r'[-_.]+', '-', s).lower()
|
|
268
|
+
if normalize(built_name) != normalize(expected_meta['name']) or built_version != expected_meta['version']:
|
|
269
|
+
raise ValueError('built package identity differs from source metadata')
|
|
270
|
+
return {'package': {'name': expected_meta['name'], 'version': expected_meta['version']}, 'files': [{'path': f.relative_to(root).as_posix(), 'artifact': f.relative_to(root).as_posix(),
|
|
271
|
+
'sha256': hashlib.sha256(f.read_bytes()).hexdigest()} for f in files]}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def publish(data, node, project, root, records, plan):
|
|
275
|
+
from .publication import (check_development_context, destination, release_channel, snapshot_version)
|
|
276
|
+
development = node['settings'].get('channel') == 'development'
|
|
277
|
+
if development:
|
|
278
|
+
check_development_context(plan)
|
|
279
|
+
current = None
|
|
280
|
+
else:
|
|
281
|
+
if os.environ.get('CI_COMMIT_REF_PROTECTED') != 'true' or plan['candidates'] or plan['event'] != 'release':
|
|
282
|
+
raise ValueError('publishing requires a protected release without candidate dependencies')
|
|
283
|
+
current = validate_version(project, root, plan)
|
|
284
|
+
directory = path_in(root, project['path'])
|
|
285
|
+
if node['settings']['publish_package']:
|
|
286
|
+
package_dir = path_in(directory, project['package']['directory'])
|
|
287
|
+
target = destination(project, package_dir, data['platform']['allowed_hosts'], development)
|
|
288
|
+
packages = [r for r in records if r['action'] == 'package' and r.get('project') == node['project']]
|
|
289
|
+
files = [path_in(root, f['artifact']) for r in packages for f in r['files']]
|
|
290
|
+
if len(packages) != 1 or not files:
|
|
291
|
+
raise ValueError('missing unique project package artifacts')
|
|
292
|
+
if development:
|
|
293
|
+
meta = tomllib.loads((package_dir / 'pyproject.toml').read_text())['project'] if project['python'] is not None else json.loads((package_dir / 'package.json').read_text())
|
|
294
|
+
current = snapshot_version(meta['version'], project['node'] is not None)
|
|
295
|
+
if packages[0].get('publication') != {'channel': 'development', 'destination': target}:
|
|
296
|
+
raise ValueError('development artifact destination differs from publication configuration')
|
|
297
|
+
if packages[0]['package']['version'] != current:
|
|
298
|
+
raise ValueError('built package version differs from publication version')
|
|
299
|
+
channel = 'dev' if development else release_channel(current, project['node'] is not None)
|
|
300
|
+
if project['python'] is not None:
|
|
301
|
+
subprocess.run(['uv', 'publish', '--index', target['index'], '--publish-url', target['url'], '--check-url', target['check_url'], *map(str, files)], cwd=package_dir, check=True)
|
|
302
|
+
else:
|
|
303
|
+
for file in files:
|
|
304
|
+
with tarfile.open(file) as archive:
|
|
305
|
+
packed = json.load(archive.extractfile('package/package.json'))
|
|
306
|
+
config = packed.get('publishConfig', {})
|
|
307
|
+
channel = config.get('tag', channel)
|
|
308
|
+
subprocess.run(['npm', 'publish', str(file), '--registry', target['url'], '--tag', channel, '--ignore-scripts'], cwd=package_dir, check=True)
|
|
309
|
+
if development:
|
|
310
|
+
return {'version': current, 'publication': {'channel': 'development', 'destination': target}}
|
|
311
|
+
tag = project['release']['tag'].format(version=current)
|
|
312
|
+
existing = api(data, 'GET', '/releases/' + urllib.parse.quote(tag, safe=''), missing=True)
|
|
313
|
+
if not existing:
|
|
314
|
+
api(data, 'POST', '/releases', {'tag_name': tag, 'name': tag, 'description': 'Published by verified pipeline ' + os.environ['CI_PIPELINE_ID']})
|
|
315
|
+
return {'version': current, 'tag': tag}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def run_job(data, key, root, expected):
|
|
319
|
+
node = data['nodes'][key]
|
|
320
|
+
out = root / '.ci-out' / key
|
|
321
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
receipt_path(root, key).unlink(missing_ok=True)
|
|
323
|
+
if node['action'] == 'stop':
|
|
324
|
+
from .helm import deploy
|
|
325
|
+
return deploy(data, node, root, out, [], {}, stop=True)
|
|
326
|
+
plan = json.loads((root / '.ci-out/plan.json').read_text())
|
|
327
|
+
if any(plan.get(k) != v for k, v in identity(expected).items()):
|
|
328
|
+
raise ValueError('stale plan')
|
|
329
|
+
if plan['event'] != node['event']:
|
|
330
|
+
raise ValueError('job event does not match plan')
|
|
331
|
+
if node['project'] and not node['deployment'] and node['project'] not in plan['selected']:
|
|
332
|
+
print('Project not selected; no success receipt emitted')
|
|
333
|
+
return
|
|
334
|
+
if node['deployment']:
|
|
335
|
+
bound = {i['from_'].split('.')[0] for i in data['pipeline']['deployments'][node['deployment']]['images']}
|
|
336
|
+
if not bound.intersection(plan['selected']):
|
|
337
|
+
print('Deployment unaffected; no success receipt emitted')
|
|
338
|
+
return
|
|
339
|
+
records = []
|
|
340
|
+
for upstream in node['needs']:
|
|
341
|
+
producer = data['nodes'][upstream]
|
|
342
|
+
deployment = data['pipeline']['deployments'].get(node['deployment'])
|
|
343
|
+
if deployment and deployment['update'] == 'partial' and producer['action'] == 'container' and producer['project'] not in plan['selected']:
|
|
344
|
+
continue
|
|
345
|
+
records.append(require_receipt(root, upstream, expected))
|
|
346
|
+
materialize(root, records)
|
|
347
|
+
project = data['pipeline']['projects'].get(node['project'])
|
|
348
|
+
action = node['action']
|
|
349
|
+
result = {**identity(expected), 'status': 'passed', 'action': action, 'project': node['project'], 'files': []}
|
|
350
|
+
if action in {'check', 'application'}:
|
|
351
|
+
settings = node['settings']
|
|
352
|
+
directory = path_in(root, project['path'])
|
|
353
|
+
environment_vars = {'DEPLOYMENT_URL': r['url'] for r in records if 'url' in r}
|
|
354
|
+
with environment(project, settings, root, node['project'], plan['candidates'], data['platform']['allowed_hosts'], out) as record:
|
|
355
|
+
result['dependencies'] = {k: v for k, v in record.items() if k != 'interpreter'}
|
|
356
|
+
commands(settings['script'], directory, 'sh', environment_vars)
|
|
357
|
+
result['files'] = collect(root, directory, settings.get('outputs', []), out)
|
|
358
|
+
elif action == 'approve':
|
|
359
|
+
pass
|
|
360
|
+
elif action == 'version':
|
|
361
|
+
# Consumer retesting does not require bumping the consumer's version.
|
|
362
|
+
if node['project'] not in plan['direct'] and plan['event'] != 'release':
|
|
363
|
+
result['version'] = read_version(path_in(root, project['path']), project['release'])
|
|
364
|
+
else:
|
|
365
|
+
result['version'] = validate_version(project, root, plan)
|
|
366
|
+
elif action == 'create-release':
|
|
367
|
+
if node['project'] not in plan['direct']:
|
|
368
|
+
print('No direct project changes; no release created')
|
|
369
|
+
return
|
|
370
|
+
result.update(create_release(data, project, root))
|
|
371
|
+
elif action == 'container':
|
|
372
|
+
result.update(build_image(data, node, root, out, plan))
|
|
373
|
+
elif action == 'package':
|
|
374
|
+
with environment(project, {}, root, node['project'], plan['candidates'], data['platform']['allowed_hosts'], out):
|
|
375
|
+
if node['settings'].get('channel') == 'development':
|
|
376
|
+
from .publication import check_development_context, destination, rewrite_snapshot
|
|
377
|
+
check_development_context(plan)
|
|
378
|
+
package_dir = path_in(path_in(root, project['path']), project['package']['directory'])
|
|
379
|
+
target = destination(project, package_dir, data['platform']['allowed_hosts'], True)
|
|
380
|
+
with tempfile.TemporaryDirectory(prefix='generic-ci-package-') as temp:
|
|
381
|
+
# Preserve workspace layout, generated files and prepared dependencies.
|
|
382
|
+
# Only the copy's manifest is rewritten, including on build failure.
|
|
383
|
+
copied = Path(temp) / 'source'
|
|
384
|
+
shutil.copytree(root, copied, symlinks=True, ignore=shutil.ignore_patterns('.git', '.ci-out'))
|
|
385
|
+
copied_dir = path_in(path_in(copied, project['path']), project['package']['directory'])
|
|
386
|
+
if (copied_dir / ('package.json' if project['node'] is not None else 'pyproject.toml')).is_symlink():
|
|
387
|
+
raise ValueError('development package manifest cannot be a symlink')
|
|
388
|
+
rewrite_snapshot(copied_dir, project['node'] is not None, target)
|
|
389
|
+
result.update(package_build(project, root, out, copied_dir))
|
|
390
|
+
result['publication'] = {'channel': 'development', 'destination': target}
|
|
391
|
+
else:
|
|
392
|
+
result.update(package_build(project, root, out))
|
|
393
|
+
elif action == 'publish':
|
|
394
|
+
result.update(publish(data, node, project, root, records, plan))
|
|
395
|
+
elif action == 'deploy':
|
|
396
|
+
from .helm import deploy
|
|
397
|
+
result.update(deploy(data, node, root, out, records, plan))
|
|
398
|
+
else:
|
|
399
|
+
raise ValueError(f'unknown action {action}')
|
|
400
|
+
write_json(receipt_path(root, key), result)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main():
|
|
404
|
+
try:
|
|
405
|
+
expected = sys.argv[-1]
|
|
406
|
+
data = load_config(expected)
|
|
407
|
+
if data.get('format') != 'workflows-v1':
|
|
408
|
+
raise ValueError('not a workflow execution configuration')
|
|
409
|
+
root = Path(os.environ.get('CI_PROJECT_DIR', '.')).resolve()
|
|
410
|
+
if sys.argv[1] == 'plan':
|
|
411
|
+
make_plan(data, root, expected)
|
|
412
|
+
elif sys.argv[1] == 'run':
|
|
413
|
+
run_job(data, sys.argv[2], root, expected)
|
|
414
|
+
else:
|
|
415
|
+
raise ValueError('expected plan or run')
|
|
416
|
+
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
|
417
|
+
print(f'generic-ci: {error}', file=sys.stderr)
|
|
418
|
+
raise SystemExit(1) from error
|
|
419
|
+
|
|
420
|
+
if __name__ == '__main__':
|
|
421
|
+
main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: generic-gitlab-cicd
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Validated project configuration and reproducible GitLab delivery pipelines
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
8
|
+
Requires-Dist: PyYAML<7,>=6
|
|
9
|
+
Requires-Dist: tomlkit<1,>=0.13
|
|
10
|
+
Requires-Dist: packaging<27,>=25
|
|
11
|
+
|
|
12
|
+
Testing commands and the local/E2E split: [testing — revision one](docs/testing-revision-one.md).
|
|
13
|
+
|
|
14
|
+
Start with the [documentation map](docs/README.md), [AI authoring guide](docs/ai-authoring-revision-three.md), or portable [generic-ci-authoring skill](skills/generic-ci-authoring/SKILL.md).
|
|
15
|
+
|
|
16
|
+
Organization defaults and Git-backed starter templates are now available: see [configuration sources — revision two](docs/configuration-sources-revision-two.md).
|
|
17
|
+
|
|
18
|
+
> Feature-branch workflow interface (0.3.1): see [implemented revision-one guide](docs/workflows-revision-one.md), [configuration examples](examples/workflows), and [JSON Schema](schemas/workflows.schema.json). This is a review build with documented integration gates. Older prototype CLI commands now require `--format legacy`; existing GitLab components remain available.
|
|
19
|
+
|
|
20
|
+
# Generic GitLab CI components — revision one
|
|
21
|
+
|
|
22
|
+
A self-contained component repository for GitLab, including an offline-capable runtime-image factory and a generic Kubernetes Helm chart. The recommended Python entry point is `examples/uv-airgap.yml`.
|
|
23
|
+
|
|
24
|
+
This is an implementation to configure and validate on your installation. It has not been run against your GitLab, Artifactory or Kubernetes cluster. Infrastructure addresses, credentials, approved base images and application deployment settings must be supplied by your organization.
|
|
25
|
+
|
|
26
|
+
## Components
|
|
27
|
+
|
|
28
|
+
| Component | Purpose |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `workflow` | Stages, cancellation of superseded work, suppression of duplicate push/MR pipelines |
|
|
31
|
+
| `task` | Language-neutral lint, test, build or verification commands; reports, caches and artifacts |
|
|
32
|
+
| `uv-test` | Locked uv validation, optional Git dependency replacement and commit provenance |
|
|
33
|
+
| `version-check` | PEP 440 version increase against the current target branch; tag/version matching |
|
|
34
|
+
| `python-package` | Build wheel/sdist once, check metadata, retain distribution artifacts |
|
|
35
|
+
| `python-publish` | Publish those artifacts to PyPI, TestPyPI, GitLab or Artifactory |
|
|
36
|
+
| `container-preview-build` | Same-project MR builds restricted to a dedicated preview repository |
|
|
37
|
+
| `container-build` | Rootless BuildKit build, digest metadata, optional protected-ref registry push |
|
|
38
|
+
| `deploy` / `preview` | Provider-neutral command adapters with deployment locking and preview cleanup |
|
|
39
|
+
| `helm-deploy` / `helm-preview` | Helm rollout with failure rollback and isolated review environments |
|
|
40
|
+
| `release` | Create a GitLab release for an existing protected tag |
|
|
41
|
+
|
|
42
|
+
Each YAML file documents its own typed inputs. Include components multiple times with unique job names. Secrets remain GitLab CI variables; they are never component inputs.
|
|
43
|
+
|
|
44
|
+
## Bootstrap
|
|
45
|
+
|
|
46
|
+
1. Import this directory into a GitLab project such as `platform/ci-components` on your own GitLab instance. Component includes cannot directly cross to an unrelated GitLab instance; mirror this repository internally.
|
|
47
|
+
2. Configure the variables in `docs/airgap.md`, and supply approved internal Python, BuildKit and Helm runtime images. The repository's own pipeline uses those images. Do not switch all consumers until the runtime images are available.
|
|
48
|
+
3. Adapt non-secret files under `images/python/config/`, add approved CA certificates, and review `images/python/requirements.lock`. Run `images.gitlab-ci.yml` to build the Python runtime from your internal package index and push it to Artifactory. Initial BuildKit and Python base images must already be mirrored and trusted.
|
|
49
|
+
4. Run this repository's validation pipeline. Publish/tag an immutable component version, e.g. `1.0.0`. Optionally mark the project as a CI/CD Catalog resource and create a GitLab release. Consumers may pin a full commit SHA before the first release.
|
|
50
|
+
5. Copy the relevant example as the consuming project's `.gitlab-ci.yml`. Replace `platform/ci-components`, the release ref and deployment placeholders.
|
|
51
|
+
6. Validate the expanded pipeline with your GitLab CI Lint, then run MR, default-branch and protected-tag scenarios before enabling production publishing.
|
|
52
|
+
|
|
53
|
+
Use a supported GitLab with CI/CD components and array inputs (GitLab 17+ syntax baseline). The optional native release job also requires a GitLab-compatible glab runtime; verify server/CLI compatibility. Runtime jobs target Linux container runners, not Windows shell runners.
|
|
54
|
+
|
|
55
|
+
## Minimal uv project
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
variables:
|
|
59
|
+
CI_DEPENDENCY_REPO: ""
|
|
60
|
+
CI_DEPENDENCY_REF: ""
|
|
61
|
+
CI_DEPENDENCY_PACKAGE: ""
|
|
62
|
+
CI_DEPENDENCY_OVERRIDES: '[]'
|
|
63
|
+
|
|
64
|
+
include:
|
|
65
|
+
- component: $CI_SERVER_FQDN/platform/ci-components/workflow@1.0.0
|
|
66
|
+
- component: $CI_SERVER_FQDN/platform/ci-components/uv-test@1.0.0
|
|
67
|
+
inputs:
|
|
68
|
+
name: api-test
|
|
69
|
+
repo: $CI_DEPENDENCY_REPO
|
|
70
|
+
ref: $CI_DEPENDENCY_REF
|
|
71
|
+
package: $CI_DEPENDENCY_PACKAGE
|
|
72
|
+
overrides-json: $CI_DEPENDENCY_OVERRIDES
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Set `PYTHON_CI_IMAGE` to your internal runtime image. Commit `uv.lock`. Put pytest and application test dependencies in the project's dependency groups. `uv-test` syncs all groups; define mutually compatible groups. Tests run with `uv run --no-sync` so uv does not undo a candidate installation.
|
|
76
|
+
|
|
77
|
+
## Cross-repository dependencies
|
|
78
|
+
|
|
79
|
+
For an unreleased dependency, start a pipeline with:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
CI_DEPENDENCY_REPO=https://gitlab.internal/team/shared-sdk.git
|
|
83
|
+
CI_DEPENDENCY_REF=feature/new-api
|
|
84
|
+
CI_DEPENDENCY_PACKAGE=shared-sdk
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For several dependencies or a package in a repository subdirectory:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
[
|
|
91
|
+
{"repo":"https://gitlab.internal/team/sdk.git", "ref":"feature/new-api", "package":"shared-sdk"},
|
|
92
|
+
{"repo":"https://gitlab.internal/team/core.git", "ref":"0123456789012345678901234567890123456789", "package":"core-utils", "subdirectory":"packages/utils"}
|
|
93
|
+
]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Set that array as `CI_DEPENDENCY_OVERRIDES`. Both mechanisms can be combined if package names do not overlap.
|
|
97
|
+
|
|
98
|
+
The helper resolves each branch/tag to a commit, applies temporary `tool.uv.override-dependencies`, removes conflicting uv source mappings for the named distribution, re-resolves, and verifies installed `direct_url.json` commit metadata. Original pyproject and lock bytes are restored even if sync fails. A successful override run saves requested refs and actual commits as a provenance artifact. A branch can move between pipelines; use the recorded SHA for reproducible reruns.
|
|
99
|
+
|
|
100
|
+
The normal path uses `uv sync --locked --all-groups`. The override path deliberately re-resolves and can change transitive dependencies; it tests candidate compatibility rather than certifying the production lock. Overrides must name an installed distribution; a typo or irrelevant package fails verification.
|
|
101
|
+
|
|
102
|
+
Configure read-only Git authentication in the job's `setup` or runner credential helper. Use credential-free repository URLs. For CI_JOB_TOKEN access, allowlist the consumer project in each dependency project. Restrict the credential helper to the intended host. Do not put credentials in URLs, artifacts, images, or tracked config.
|
|
103
|
+
|
|
104
|
+
Run a uv workspace from its root; its virtual environment must be `.venv` there. For independent monorepo projects, include one `uv-test` per project and use unique report/provenance paths. Custom UV_PROJECT_ENVIRONMENT layouts require adapting the helper's virtual-environment path.
|
|
105
|
+
|
|
106
|
+
## Coordinated releases
|
|
107
|
+
|
|
108
|
+
Candidate testing solves the validation deadlock. It does not make multiple registry publications transactional.
|
|
109
|
+
|
|
110
|
+
1. Validate the application and dependency candidates together using immutable overrides.
|
|
111
|
+
2. Publish the dependency's real version to the internal package repository.
|
|
112
|
+
3. Update/validate the application's production lock against that registry, with overrides cleared.
|
|
113
|
+
4. Build once, publish/deploy the application, then create its GitLab release.
|
|
114
|
+
|
|
115
|
+
A dependency package can usually be published before the application is deployed; publishing the wheel does not deploy a running service. For stronger coordination, use an internal candidate repository and promote approved packages before deployment. Two independent GitLab projects still need an orchestration policy; this framework does not promise atomic cross-project rollback. Multi-project trigger/approval coordination is an extension point, not implemented here.
|
|
116
|
+
|
|
117
|
+
The standard publish and production deploy jobs reject nonempty `CI_DEPENDENCY_*` override variables. Do not hard-code candidate inputs in a production pipeline. Preview artifacts may intentionally contain candidate dependencies; never promote those to production without a clean release validation.
|
|
118
|
+
|
|
119
|
+
## Versioning, tags and releases
|
|
120
|
+
|
|
121
|
+
`version-check` reads static `[project].version`. In branch/MR pipelines it fetches the current target branch and requires a strict PEP 440 increase. On tags it requires the tag suffix to match the package version. The default prefix is `v`; use `sdk-v` for independent package tags. Dynamic SCM versions require a custom command via `task`.
|
|
122
|
+
|
|
123
|
+
Use change rules to restrict bump checks to meaningful package changes if documentation-only MRs should not bump a version. The default checks every selected non-default branch pipeline. `allow-new-package` explicitly permits a manifest absent from the target branch.
|
|
124
|
+
|
|
125
|
+
Tags are an input to the release pipeline. Create and protect them through your established process; CI does not silently create or push tags. Package publishing and production deployment are blocking manual jobs on protected tags by default. The GitLab release runs last, using `CHANGELOG.md` or a configured notes file. Existing GitLab release retries require deliberate handling; no overwrite or package `--skip-existing` behavior hides duplicate releases.
|
|
126
|
+
|
|
127
|
+
A tag check validates manifest metadata, not the version assigned by every possible custom build backend. The build defaults use static PEP 621 metadata. Custom build commands should add wheel metadata/version assertions if they derive or rewrite versions.
|
|
128
|
+
|
|
129
|
+
## Monorepos and cost
|
|
130
|
+
|
|
131
|
+
`examples/monorepo.yml` demonstrates independent service rules, shared dependency paths and service-specific tag names. Include every shared source/config/lock path that can affect a service; transitive dependency graph discovery is not automatic. `CI_FULL_PIPELINE=true` and schedules run full verification. Tags rebuild all package units in that example, so a release never consumes artifacts from a different pipeline.
|
|
132
|
+
|
|
133
|
+
The defaults preserve stage barriers: lint → test → build → verify → publish → deploy → release. Jobs use `dependencies` only to select artifacts. No default `needs: []` bypasses validation. All enabled tests must pass before any build proceeds. Stage barriers are conservative across unrelated services; customize explicit DAG dependencies only after keeping release gates intact.
|
|
134
|
+
|
|
135
|
+
Tests fail normally; only runner/system failures retry once. Deploy/publish operations never retry automatically. Superseded interruptible work can be cancelled. Use `task` cache controls for package download caches, with lockfile-derived keys; keep protected/unprotected cache separation enabled. uv's download cache is safe to rebuild and is not proof of dependency correctness.
|
|
136
|
+
|
|
137
|
+
## Helm deployment
|
|
138
|
+
|
|
139
|
+
Use `charts/generic-app`, `examples/helm-values.yaml`, and `docs/kubernetes.md`. Helm manages the application release; Terraform is appropriate for cluster, DNS, network and registry infrastructure outside this repository's scope.
|
|
140
|
+
|
|
141
|
+
Deploy digest-pinned images from the exact build artifacts. `helm-deploy.image-map-json` maps application names to image repositories and BuildKit metadata files; the helper extracts digests into an overlay passed to Helm. One Helm release may deploy several services, or use separate releases for independently deployable services.
|
|
142
|
+
|
|
143
|
+
## Validation
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
python -m pip install -r requirements-dev.txt
|
|
147
|
+
python scripts/sync_embedded.py --check
|
|
148
|
+
python -m unittest discover -s tests -v
|
|
149
|
+
python tests/integration_uv.py
|
|
150
|
+
helm lint charts/generic-app -f examples/helm-values.yaml --strict
|
|
151
|
+
helm template smoke charts/generic-app -f examples/helm-values.yaml
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The integration test uses local Git repositories and URL rewriting; it does not contact a real dependency host or publish anything. The lightweight component expander tests inputs, names and artifact references. It is not GitLab's server-side compiler. Do not interpret local tests as proof that a specific runner, RBAC policy, Artifactory endpoint or cluster deployment works.
|
|
155
|
+
|
|
156
|
+
## Official references
|
|
157
|
+
|
|
158
|
+
- GitLab components: https://docs.gitlab.com/ci/components/
|
|
159
|
+
- GitLab input types: https://docs.gitlab.com/ci/inputs/
|
|
160
|
+
- GitLab environment teardown: https://docs.gitlab.com/ci/environments/
|
|
161
|
+
- Rootless BuildKit: https://docs.gitlab.com/ci/docker/using_buildkit/
|
|
162
|
+
- GitLab releases: https://docs.gitlab.com/user/project/releases/release_cicd_examples/
|
|
163
|
+
- uv overrides: https://docs.astral.sh/uv/concepts/resolution/
|
|
164
|
+
- uv configuration: https://docs.astral.sh/uv/reference/settings/
|
|
165
|
+
- PyPI trusted publishing: https://docs.pypi.org/trusted-publishers/using-a-publisher/
|
|
166
|
+
- Helm upgrade: https://helm.sh/docs/helm/helm_upgrade/
|
|
167
|
+
|
|
168
|
+
## Publishing this toolkit to PyPI
|
|
169
|
+
|
|
170
|
+
The PyPI distribution is `generic-gitlab-cicd`; its CLI remains `generic-ci`. The [Publish to PyPI workflow](.github/workflows/publish.yml) uses Trusted Publishing on a published GitHub release or a manual run. See [publisher configuration and release steps](docs/pypi-release.md).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
generic_ci/__init__.py,sha256=7gpTNNcCDrmt382WW40tScoMJPUMikRufhLl4T2egXw,106
|
|
2
|
+
generic_ci/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
generic_ci/cli.py,sha256=Pj6LdtwykT4mq08Trs3h0F5fABTwWCpssh9nJvdoha8,6087
|
|
4
|
+
generic_ci/compiler.py,sha256=klfakk3Xoc1P8wfavm6c-cZcL7bJKw6hQw8TbDWviLo,14005
|
|
5
|
+
generic_ci/config.py,sha256=rmSZJleenhz8DCHC68QGPkn4G7bomjdZP_ruCneoZIM,7745
|
|
6
|
+
generic_ci/dependencies.py,sha256=9ILs48hYhW047FTC9jQs5umgfH0kQu5xA3F5yPvwaEY,9844
|
|
7
|
+
generic_ci/models.py,sha256=owwvpXgJTSaoXWoKpCHHAw6OXQHG9ta6_VmgNbjzgvk,8828
|
|
8
|
+
generic_ci/runtime.py,sha256=15q6YugiFKYYzV6Z3AQDr6_GGbVtF_k-QGOMd3vvMs8,27782
|
|
9
|
+
generic_ci/sources.py,sha256=KcvlUjA32zdHmS6l8dmvc-zttO9GwWVwC0Or6E9FgI4,14542
|
|
10
|
+
generic_ci/workflows/__init__.py,sha256=pGAo3jDFfym_rWsJ_HGTkdVzT4eL9LYZtbIJGuZ3MUI,49
|
|
11
|
+
generic_ci/workflows/compiler.py,sha256=nllDE-qvf_gza8rFXzPUjxEHTfwIBG_D_JVSc9xqD0g,18806
|
|
12
|
+
generic_ci/workflows/ecosystems.py,sha256=w8SlDuHd06AJOlv3JymwZE77jCnSVcFHr9S3bQVQXvo,10444
|
|
13
|
+
generic_ci/workflows/helm.py,sha256=NA8JXdoaZ1n2-Nnv3-wHUwlLYAmp1oiMzUQ48lZ_M2U,9203
|
|
14
|
+
generic_ci/workflows/models.py,sha256=OI5bpjND6L8jfHRATkPbyIWePuGDZl9pUk1KHZYrDN8,9879
|
|
15
|
+
generic_ci/workflows/publication.py,sha256=Yit9ZbQiYGH0dgx12Qd4a2ahUNJEPiW94wmUgGDblQg,3636
|
|
16
|
+
generic_ci/workflows/runtime.py,sha256=0Ng9pN74VQbXZm0F8obm9i2fpIWEIemlANnOZr6vb9w,24467
|
|
17
|
+
generic_gitlab_cicd-0.3.2.dist-info/METADATA,sha256=24YZQY64cj5xmrRx1ZChzklkdklWVFzyjsx6CU3xRMI,13608
|
|
18
|
+
generic_gitlab_cicd-0.3.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
19
|
+
generic_gitlab_cicd-0.3.2.dist-info/entry_points.txt,sha256=v1ZgY-7kmUo5JEC8ilNrYcjdYx2g1BClDW6l-g6KSGI,51
|
|
20
|
+
generic_gitlab_cicd-0.3.2.dist-info/top_level.txt,sha256=f46-VP1dRS-uav36Uw1n9JF93l-F98flDunBGJybsxo,11
|
|
21
|
+
generic_gitlab_cicd-0.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
generic_ci
|