Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85711666
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
23 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f5c2453..949fd3e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,56 +1,57 @@
stages:
- unit-test
- build
default:
image: quay.io/podman/stable
- before_script:
- - 'if [ -n "${CI_COMMIT_REF_SLUG}" ]; then sudo -u podman podman login -u "$REGISTRY_USER" --password-stdin "$REGISTRY" <<< "$REGISTRY_PASSWORD"; fi'
- - IMAGE_PREFIX="$REGISTRY/infra/lilybuild"
- - IMAGE_VER="${CI_COMMIT_REF_SLUG-none}"
.push:
script: &push
- if [ -n "${CI_COMMIT_REF_SLUG}" ]; then sudo -u podman podman push "$IMAGE"; fi
unit-test-master:
stage: unit-test
image: docker.io/buildbot/buildbot-master:v4.2.1
- before_script: []
script:
- /buildbot_venv/bin/pip3 install jsonschema backports.tarfile
- . /buildbot_venv/bin/activate
- ./lilybuild/run-tests.sh master
unit-test-worker:
stage: unit-test
image: alpine
- before_script: []
script:
- apk add --no-cache python3 py3-virtualenv
- virtualenv --python=python3 /buildbot_venv
- /buildbot_venv/bin/pip3 install 'twisted[tls]' jsonschema backports.tarfile
- . /buildbot_venv/bin/activate
- ./lilybuild/run-tests.sh worker
-build:master:
+.build:
stage: build
+ before_script:
+ - 'if [ -n "${CI_COMMIT_REF_SLUG}" ]; then sudo -u podman podman login -u "$REGISTRY_USER" --password-stdin "$REGISTRY" <<< "$REGISTRY_PASSWORD"; fi'
+ - IMAGE_PREFIX="$REGISTRY/infra/lilybuild"
+ - IMAGE_VER="${CI_COMMIT_REF_SLUG-none}"
+
+build:master:
+ extends: .build
script:
- IMAGE="$IMAGE_PREFIX/buildbot-master:$IMAGE_VER"
- sudo -u podman ./build-master.sh -t "$IMAGE"
- *push
build:worker:
- stage: build
+ extends: .build
script:
- IMAGE="$IMAGE_PREFIX/buildbot-worker:$IMAGE_VER"
- sudo -u podman ./build-worker.sh -t "$IMAGE"
- *push
build:volume-helper:
- stage: build
+ extends: .build
script:
- IMAGE="$IMAGE_PREFIX/volume-helper:$IMAGE_VER"
- sudo -u podman ./build-volume-helper.sh -t "$IMAGE"
- *push
diff --git a/lilybuild/lilybuild/ci_syntax/ci_file.py b/lilybuild/lilybuild/ci_syntax/ci_file.py
index d052b69..fc78869 100644
--- a/lilybuild/lilybuild/ci_syntax/ci_file.py
+++ b/lilybuild/lilybuild/ci_syntax/ci_file.py
@@ -1,180 +1,229 @@
import yaml
import jsonschema
import json
import os
import re
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/editor/schema/ci.json
schema_file = os.path.join(os.path.dirname(__file__), 'ci.json')
schema = None
+extend_limit = 11
+
+class CIValidationError(Exception):
+ pass
def get_schema():
global schema
if schema is not None:
return schema
else:
with open(schema_file, 'r') as f:
schema = json.loads(f.read())
return schema
def toplevel_entries():
return get_schema()['properties']
def normalize_script(script):
if script is None:
return []
elif isinstance(script, str):
return [script]
else:
res = []
for l in script:
if isinstance(l, str):
res.append(l)
else:
res += l
return res
slug_re = re.compile('[^0-9a-z]')
def ci_slugify(s):
return re.sub(slug_re, '-', s.lower()[:63]).strip('-')
+def get_job_extend_seq(job_name, all_jobs, depth=0):
+ # DFS traversal; child-first, self-last order
+ res = []
+ if job_name not in all_jobs:
+ raise CIValidationError(f'Job "{job_name}" does not exist, but occurs in `extends`.')
+ job_struct = all_jobs.get(job_name) or {}
+ parents = job_struct.get('extends', [])
+ if depth > extend_limit:
+ raise CIValidationError(f'`extends` depth is over the limit of {extend_limit}.')
+ if isinstance(parents, str):
+ parents = [parents]
+ for p in parents:
+ res += get_job_extend_seq(p, all_jobs, depth + 1)
+ res.append(job_name)
+
+ return res
+
+depth_limit = 20
+def merge_job_deep(res, parent, depth=0):
+ res_keys = set(res.keys())
+ if depth > depth_limit:
+ raise CIValidationError(f'depth is over the limit when merging job.')
+ for k in parent:
+ if k not in res_keys:
+ res[k] = parent[k]
+ elif isinstance(parent[k], dict) and isinstance(res[k], dict):
+ child = res[k]
+ # Necessary to avoid changing anything inside res[k] that was shallow-copied
+ res[k] = {}
+ merge_job_deep(res[k], child, depth + 1)
+ merge_job_deep(res[k], parent[k], depth + 1)
+ else:
+ # Presenting in both parent and child, and it's not a dict,
+ # so the child should take preference.
+ pass
+
+def expand_job(job_name, all_jobs, defaults):
+ seq = get_job_extend_seq(job_name, all_jobs)
+ res = {}
+ for ancestor_name in reversed(seq):
+ merge_job_deep(res, all_jobs[ancestor_name])
+ merge_job_deep(res, defaults)
+ if 'extends' in res:
+ del res['extends']
+ return res
+
class CIJob:
- def __init__(self, job_name, job_stage, job_struct, defaults):
+ def __init__(self, job_name, job_stage, job_struct):
self.name = job_name
self.stage = job_stage
self.struct_raw = job_struct
- self.defaults_raw = defaults
- self.image = job_struct.get('image', defaults.get('image'))
- self.before_script = normalize_script(job_struct.get('before_script', defaults.get('before_script')))
- self.script = normalize_script(job_struct.get('script', defaults.get('script')))
- self.after_script = normalize_script(job_struct.get('after_script', defaults.get('after_script')))
- self.artifacts = job_struct.get('artifacts', defaults.get('artifacts', {}))
+ self.image = job_struct.get('image')
+ self.before_script = normalize_script(job_struct.get('before_script'))
+ self.script = normalize_script(job_struct.get('script'))
+ self.after_script = normalize_script(job_struct.get('after_script'))
+ self.artifacts = job_struct.get('artifacts') or {}
self.dependencies = job_struct.get('dependencies')
def get_predefined_ci_variables(self):
return {
'CI': 'true',
'CI_JOB_NAME': self.name,
'CI_JOB_NAME_SLUG': ci_slugify(self.name),
'CI_JOB_STAGE': self.stage,
}
def has_artifacts_archive(self):
return self.artifacts and 'paths' in self.artifacts
def to_prop(self):
return {
'name': self.name,
'stage': self.stage,
'struct_raw': self.struct_raw,
- 'defaults_raw': self.defaults_raw,
}
@classmethod
def from_prop(cls, prop):
return cls(
prop['name'],
prop['stage'],
- prop['struct_raw'],
- prop['defaults_raw']
+ prop['struct_raw']
)
def is_pages(self):
return self.name == 'pages' or self.struct_raw.get('pages', False)
OLD_TOPLEVEL_DEFAULTS = ['image', 'services', 'cache', 'before_script', 'after_script']
DEFAULT_STAGES = ['.pre', 'build', 'test', 'deploy', '.post']
DEFAULT_JOB_STAGE = 'test'
-class CIValidationError(Exception):
- pass
-
class CIFile:
'''
Class for parsing CI file.
'''
def __init__(self, file_content):
'''
Construct a CI File from its text content.
'''
f = yaml.safe_load(file_content)
if f is None:
self.stages = []
self.jobs = {}
return
jsonschema.validate(instance=f, schema=get_schema())
self.stages = f.get('stages', DEFAULT_STAGES)
self.jobs = {}
defaults = f.get('default', {})
if f.get('variables'):
defaults['variables'] = f['variables']
for kw in OLD_TOPLEVEL_DEFAULTS:
if kw not in defaults and kw in f:
defaults[kw] = f[kw]
+ all_jobs = {}
for job_name, job_struct in f.items():
# 'pages' is a special job
if job_name != 'pages' and job_name in toplevel_entries():
continue
+ all_jobs[job_name] = job_struct
+
+ for job_name in all_jobs:
# jobs starting with . will only be used for base jobs of other jobs,
# they themselves are not run
if job_name.startswith('.'):
continue
+ job_struct = expand_job(job_name, all_jobs, defaults)
job_stage = job_struct.get('stage', DEFAULT_JOB_STAGE)
if job_stage not in self.stages:
raise CIValidationError(f'Job "{job_name}": Stage "{job_stage}" is not specified in CI file')
- self.jobs[job_name] = CIJob(job_name, job_stage, job_struct, defaults)
+ self.jobs[job_name] = CIJob(job_name, job_stage, job_struct)
self.validate_logic()
def validate_logic(self):
for job_name in self.jobs:
self.validate_job(job_name)
def validate_job(self, job_name):
j = self.jobs[job_name]
my_stage_order = self.stage_order(job_name)
if j.dependencies is not None:
for d in j.dependencies:
try:
stage_order = self.stage_order(d)
except KeyError:
raise CIValidationError(f'Dependency "{d}" of job "{job_name}" does not exist')
if stage_order >= my_stage_order:
raise CIValidationError(f'Dependency "{d}" of job "{job_name}" is not before the job in stage')
def stage_order(self, jn):
return self.stages.index(self.jobs[jn].stage)
def get_grouped_jobs(self):
groups = {}
for job_name in self.jobs:
job = self.jobs[job_name]
if job.stage not in groups:
groups[job.stage] = []
groups[job.stage].append(job)
res = []
for stage in self.stages:
if stage in groups:
res.append((stage, groups[stage]))
return res
def get_jobs_to_pull_artifacts_from(self, job_name):
'''
Get a list of names of jobs of which the artifacts will be pulled
from for the job named `job_name`.
'''
dependencies = self.jobs[job_name].dependencies
if dependencies is None:
dependencies = []
cur_job_stage_order = self.stage_order(job_name)
for jn in self.jobs:
so = self.stage_order(jn)
if so < cur_job_stage_order:
dependencies.append(jn)
return dependencies
diff --git a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
index e906bce..c612adf 100644
--- a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
+++ b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
@@ -1,109 +1,304 @@
import unittest
import yaml
-from lilybuild.ci_syntax.ci_file import CIFile, CIValidationError
+from lilybuild.ci_syntax.ci_file import CIFile, CIValidationError, get_job_extend_seq, merge_job_deep
from lilybuild.tests.resources import get_res
class CIFileTest(unittest.TestCase):
def test_empty(self):
r = CIFile('')
self.assertEqual(r.stages, [])
self.assertEqual(r.jobs, {})
def test_invalid(self):
with self.assertRaises(yaml.composer.ComposerError) as m:
r = CIFile('*xxx')
def test_simple(self):
r = CIFile(get_res('simple'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].before_script, ['ls'])
self.assertEqual(r.jobs['build-a'].script, ['make', 'make install'])
self.assertEqual(r.jobs['build-a'].after_script, ['find', 'echo ok'])
def test_noscript(self):
r = CIFile(get_res('noscript'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].script, [])
def test_defaults(self):
r = CIFile(get_res('defaults'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].before_script, ['ls'])
self.assertEqual(r.jobs['build-a'].script, ['make', 'make install'])
self.assertEqual(r.jobs['build-a'].after_script, ['find', 'echo ok'])
def test_pages(self):
r = CIFile(get_res('pages'))
self.assertEqual(r.stages, ['test'])
self.assertEqual(list(r.jobs), ['pages'])
self.assertEqual(r.jobs['pages'].script, ['make docs'])
self.assertTrue(r.jobs['pages'].is_pages())
def test_pages_attr(self):
r = CIFile(get_res('pages_attr'))
self.assertFalse(r.jobs['not-pages'].is_pages())
self.assertTrue(r.jobs['is-pages'].is_pages())
def test_dotjobs(self):
r = CIFile(get_res('dotjobs'))
self.assertEqual(list(r.jobs), [])
def test_no_such_stage(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('no_such_stage'))
def test_default_stages(self):
r = CIFile(get_res('default_stages'))
self.assertEqual(r.jobs['a'].stage, 'build')
self.assertEqual(r.jobs['b'].stage, 'test')
self.assertEqual(r.jobs['c'].stage, 'deploy')
self.assertEqual(r.jobs['d'].stage, '.pre')
self.assertEqual(r.jobs['e'].stage, '.post')
self.assertEqual(r.jobs['f'].stage, 'test')
def test_group_jobs(self):
r = CIFile(get_res('group_jobs'))
groups = r.get_grouped_jobs()
self.assertEqual(len(groups), 2)
self.assertEqual(groups[0][0], 'build')
self.assertEqual(len(groups[0][1]), 2)
self.assertTrue(next(j for j in groups[0][1] if j.name == 'a'))
self.assertTrue(next(j for j in groups[0][1] if j.name == 'b'))
self.assertEqual(groups[1][0], 'test')
self.assertEqual(len(groups[1][1]), 2)
self.assertTrue(next(j for j in groups[1][1] if j.name == 'c'))
self.assertTrue(next(j for j in groups[1][1] if j.name == 'd'))
def test_dependencies(self):
r = CIFile(get_res('dependencies'))
self.assertEqual(r.get_jobs_to_pull_artifacts_from('a'), [])
self.assertEqual(r.get_jobs_to_pull_artifacts_from('a2'), [])
self.assertEqual(r.get_jobs_to_pull_artifacts_from('b'), ['a'])
self.assertEqual(set(r.get_jobs_to_pull_artifacts_from('b2')), set(['a', 'a2']))
def test_nonexistent_dep(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('nonexistent_dep'))
self.assertEqual(str(m.exception), 'Dependency "a3" of job "b" does not exist')
def test_late_dep(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('late_dep'))
self.assertEqual(str(m.exception), 'Dependency "a2" of job "a" is not before the job in stage')
+ def test_artifacts(self):
+ r = CIFile(get_res('has_artifacts_archive'))
+ self.assertEqual(r.jobs['has_archive'].artifacts, { 'paths': ['a'] })
+ self.assertEqual(r.jobs['no_archive'].artifacts, {})
+ self.assertEqual(r.jobs['no_artifacts'].artifacts, {})
+
def test_has_artifacts_archive(self):
r = CIFile(get_res('has_artifacts_archive'))
self.assertTrue(r.jobs['has_archive'].has_artifacts_archive())
self.assertFalse(r.jobs['no_archive'].has_artifacts_archive())
self.assertFalse(r.jobs['no_artifacts'].has_artifacts_archive())
+ def test_extends(self):
+ r = CIFile(get_res('extends'))
+ self.assertEqual(r.jobs['build-a'].struct_raw, {
+ 'image': 'alpine',
+ 'variables': { 'SECRET': 'abcdef', 'CC': 'gcc' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'build',
+ 'before_script': ['apk add gcc'],
+ 'script': ['make a', 'make install'],
+ })
+ self.assertEqual(r.jobs['build-b'].struct_raw, {
+ 'image': 'alpine',
+ 'variables': { 'SECRET': 'abcdef', 'CC': 'cc' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'build',
+ 'before_script': ['apk add gcc'],
+ 'script': ['make b', 'make install'],
+ })
+ self.assertEqual(r.jobs['test'].struct_raw, {
+ 'image': 'tester',
+ 'variables': { 'SECRET': 'abcdef' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'test',
+ 'script': ['make test'],
+ })
+ self.assertEqual(r.jobs['container-amd64'].struct_raw, {
+ 'image': 'podman',
+ 'variables': { 'SECRET': 'abcdef', 'DOCKER': 'podman', 'PLATFORM': 'amd64', 'LOGIN': 'defghi' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'deploy',
+ 'script': ['podman login', 'make docker'],
+ 'tags': ['podman']
+ })
+ self.assertEqual(r.jobs['container-aarch64'].struct_raw, {
+ 'image': 'podman',
+ 'variables': { 'SECRET': 'abcdef', 'DOCKER': 'podman', 'PLATFORM': 'aarch64', 'LOGIN': 'defghi' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'deploy',
+ 'script': ['podman login', 'make docker'],
+ 'tags': ['podman']
+ })
+ self.assertEqual(r.jobs['appimage-amd64'].struct_raw, {
+ 'image': 'alpine',
+ 'variables': { 'SECRET': 'abcdef', 'PLATFORM': 'amd64', 'LOGIN': 'defghi' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'deploy',
+ 'script': ['make appimage'],
+ 'tags': ['amd64']
+ })
+ self.assertEqual(r.jobs['appimage-aarch64'].struct_raw, {
+ 'image': 'alpine',
+ 'variables': { 'SECRET': 'abcdef', 'PLATFORM': 'aarch64', 'LOGIN': 'defghi' },
+ 'after_script': ['rm -r cache'],
+ 'stage': 'deploy',
+ 'script': ['make appimage'],
+ 'tags': ['aarch64']
+ })
+
+class GetJobExtendSeqTest(unittest.TestCase):
+ def test_no_extends(self):
+ all_jobs = {
+ 'nothing': {}
+ }
+ self.assertEqual(get_job_extend_seq('nothing', all_jobs), ['nothing'])
+
+ def test_multi_extends(self):
+ all_jobs = {
+ 'a': { 'extends': ['b', 'c'] },
+ 'b': { 'extends': ['d', 'e'] },
+ 'c': { 'extends': ['f', 'g'] },
+ 'd': {},
+ 'e': {},
+ 'f': {},
+ 'g': {},
+ }
+ self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'e', 'b', 'f', 'g', 'c', 'a'])
+
+ def test_common_ancestor(self):
+ all_jobs = {
+ 'a': { 'extends': ['b', 'c'] },
+ 'b': { 'extends': ['d', 'e'] },
+ 'c': { 'extends': ['e', 'd'] },
+ 'd': {},
+ 'e': {},
+ 'f': {},
+ 'g': {},
+ }
+ self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'e', 'b', 'e', 'd', 'c', 'a'])
+
+ def test_self_cycle(self):
+ all_jobs = {
+ 'a': { 'extends': ['a'] },
+ }
+ with self.assertRaises(CIValidationError) as m:
+ r = get_job_extend_seq('a', all_jobs)
+ self.assertTrue(str(m.exception).startswith('`extends` depth is over the limit of'))
+
+ def test_complex_cycle(self):
+ all_jobs = {
+ 'a': { 'extends': ['b', 'c'] },
+ 'b': { 'extends': ['d', 'c'] },
+ 'c': { 'extends': ['d'] },
+ 'd': { 'extends': ['a'] },
+ }
+ with self.assertRaises(CIValidationError) as m:
+ r = get_job_extend_seq('a', all_jobs)
+ self.assertTrue(str(m.exception).startswith('`extends` depth is over the limit of'))
+
+ def test_nonexistent_parent(self):
+ all_jobs = {
+ 'a': { 'extends': ['b', 'c'] },
+ 'b': { 'extends': ['d', 'c'] },
+ 'c': { 'extends': ['d'] },
+ }
+ with self.assertRaises(CIValidationError) as m:
+ r = get_job_extend_seq('a', all_jobs)
+ self.assertEqual(str(m.exception), 'Job "d" does not exist, but occurs in `extends`.')
+
+ def test_null_parent(self):
+ all_jobs = {
+ 'a': { 'extends': ['b', 'c'] },
+ 'b': { 'extends': ['d', 'c'] },
+ 'c': { 'extends': ['d'] },
+ 'd': None,
+ }
+ self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'd', 'c', 'b', 'd', 'c', 'a'])
+
+class MergeJobTest(unittest.TestCase):
+ def test_simple(self):
+ parent = {
+ 'a': {
+ 'a/1': {
+ 'a/1/1': '3',
+ 'a/1/2': 4,
+ },
+ 'a/2': {},
+ },
+ 'b': 1,
+ }
+ child = {
+ 'a': {
+ 'a/1': {
+ 'a/1/2': '6',
+ 'a/1/3': 5,
+ },
+ 'a/2': 'mew',
+ },
+ 'c': 'test',
+ }
+ merge_job_deep(child, parent)
+ self.assertEqual(parent, {
+ 'a': {
+ 'a/1': {
+ 'a/1/1': '3',
+ 'a/1/2': 4,
+ },
+ 'a/2': {},
+ },
+ 'b': 1,
+ })
+ self.assertEqual(child, {
+ 'a': {
+ 'a/1': {
+ 'a/1/1': '3',
+ 'a/1/2': '6',
+ 'a/1/3': 5,
+ },
+ 'a/2': 'mew',
+ },
+ 'b': 1,
+ 'c': 'test',
+ })
+
+ def test_no_overflow(self):
+ a = {
+ '1': '2',
+ }
+ b = {
+ '2': '4',
+ 'a': a,
+ }
+ a['a'] = b
+
+ res = {}
+ merge_job_deep(res, b)
+ with self.assertRaises(CIValidationError) as m:
+ merge_job_deep(res, a)
+ self.assertEqual(str(m.exception), 'depth is over the limit when merging job.')
+
if __name__ == '__main__':
unittest.main()
diff --git a/lilybuild/lilybuild/tests/ci_syntax/res/extends.yaml b/lilybuild/lilybuild/tests/ci_syntax/res/extends.yaml
new file mode 100644
index 0000000..3aef42f
--- /dev/null
+++ b/lilybuild/lilybuild/tests/ci_syntax/res/extends.yaml
@@ -0,0 +1,83 @@
+
+variables:
+ SECRET: 'abcdef'
+
+default:
+ image: alpine
+ after_script:
+ - rm -r cache
+
+stages:
+ - build
+ - test
+ - deploy
+
+.build:
+ stage: build
+ before_script:
+ - apk add gcc
+ variables:
+ CC: gcc
+
+build-a:
+ extends: .build
+ script:
+ - make a
+ - make install
+
+build-b:
+ extends: .build
+ variables:
+ CC: cc
+ script:
+ - make b
+ - make install
+
+test:
+ image: tester
+ stage: test
+ script:
+ - make test
+
+.deploy:
+ stage: deploy
+ variables:
+ LOGIN: 'defghi'
+
+.container:
+ variables:
+ DOCKER: 'podman'
+ image: podman
+ script:
+ - podman login
+ - make docker
+ tags:
+ - podman
+
+.appimage:
+ script:
+ - make appimage
+
+.deploy-amd64:
+ variables:
+ PLATFORM: amd64
+ extends: .deploy
+ tags: [amd64]
+
+.deploy-aarch64:
+ variables:
+ PLATFORM: aarch64
+ extends: .deploy
+ tags: [aarch64]
+
+container-amd64:
+ extends: [.deploy-amd64, .container]
+
+container-aarch64:
+ extends: [.deploy-aarch64, .container]
+
+appimage-amd64:
+ extends: [.deploy-amd64, .appimage]
+
+appimage-aarch64:
+ extends: [.deploy-aarch64, .appimage]
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 19, 7:02 AM (1 d, 15 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769053
Default Alt Text
(23 KB)
Attached To
Mode
rB lilybuild
Attached
Detach File
Event Timeline
Log In to Comment