[fenix] RELENG-489 - Add Beetmover functionality
Bug 1614763 - [ci] Create beetmover tasks to publish release artifacts Added TODO Added head_tag to try_task_config Change locale to multi and fix beetmover URL destinations Bump version Adjust beetmover kind and add beta + format task label Allow all build types for beetmover Bump version Adjust beetmover destination URL + minor mods Try task config - nightly Change try release to beta Remove try_task_configpull/600/head
parent
4b3e25dcf6
commit
b306367a1c
@ -0,0 +1,28 @@
|
|||||||
|
# This source code form is subject to the terms of the mozilla public
|
||||||
|
# license, v. 2.0. if a copy of the mpl was not distributed with this
|
||||||
|
# file, you can obtain one at http://mozilla.org/mpl/2.0/.
|
||||||
|
---
|
||||||
|
loader: fenix_taskgraph.loader.multi_dep:loader
|
||||||
|
|
||||||
|
group-by: build-type
|
||||||
|
|
||||||
|
transforms:
|
||||||
|
- fenix_taskgraph.transforms.multi_dep:transforms
|
||||||
|
- fenix_taskgraph.transforms.beetmover:transforms
|
||||||
|
- taskgraph.transforms.task:transforms
|
||||||
|
|
||||||
|
kind-dependencies:
|
||||||
|
- signing
|
||||||
|
|
||||||
|
primary-dependency: signing
|
||||||
|
|
||||||
|
only-for-build-types:
|
||||||
|
- release
|
||||||
|
- beta
|
||||||
|
- nightly
|
||||||
|
|
||||||
|
job-template:
|
||||||
|
attributes:
|
||||||
|
artifact_map: taskcluster/fenix_taskgraph/manifests/fenix_candidates.yml
|
||||||
|
treeherder:
|
||||||
|
job-symbol: BM
|
@ -0,0 +1,87 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
---
|
||||||
|
# This file contains exhaustive information about all the release artifacs that
|
||||||
|
# are needed within a type of release.
|
||||||
|
#
|
||||||
|
# Structure
|
||||||
|
# --------
|
||||||
|
# `s3_bucket_paths` -- prefix to be used per product to correctly access our S3 buckets
|
||||||
|
# `default_locales` -- list of locales to be used when composing upstream artifacts or the list of
|
||||||
|
# destinations. If given an empty locale, it uses these locales instead.
|
||||||
|
# `tasktype_map` -- mapping between task reference and task type, particularly usefule when
|
||||||
|
# composing the upstreamArtifacts for scriptworker.
|
||||||
|
# `platform_names` -- various platform mappings used in reckoning artifacts or other paths
|
||||||
|
# `default` -- a default entry, which the mappings extend and override in such a way that
|
||||||
|
# final path full-destinations will be a concatenation of the following:
|
||||||
|
# `s3_bucket_paths`, `destinations`, `locale_prefix`, `pretty_name`
|
||||||
|
# `from` -- specifies the dependency(ies) from which to expect the particular artifact
|
||||||
|
# `all_locales` -- boolean argument to specify whether that particular artifact is to be expected
|
||||||
|
# for all locales or just the default one
|
||||||
|
# `description` -- brief summary of what that artifact is
|
||||||
|
# `locale_prefix` -- prefix to be used in the final destination paths, whether that's for default locale or not
|
||||||
|
# `source_path_modifier` -- any parent dir that might be used in between artifact prefix and filename at source location
|
||||||
|
# for example `public/build` vs `public/build/ach/`.
|
||||||
|
# `destinations` -- final list of directories where to push the artifacts in S3
|
||||||
|
# `pretty_name` -- the final name the artifact will have at destination
|
||||||
|
# `checksums_path` -- the name to identify one artifact within the checksums file
|
||||||
|
# `not_for_platforms` -- filtering option to avoid associating an artifact with a specific platform
|
||||||
|
# `only_for_platforms` -- filtering option to exclusively include the association of an artifact for a specific platform
|
||||||
|
# `partials_only` -- filtering option to avoid associating an artifact unless this flag is present
|
||||||
|
# `update_balrog_manifest`-- flag needed downstream in beetmover jobs to reckon the balrog manifest
|
||||||
|
# `from_buildid` -- flag needed downstream in beetmover jobs to reckon the balrog manifest
|
||||||
|
|
||||||
|
s3_bucket_paths:
|
||||||
|
by-release-type:
|
||||||
|
nightly:
|
||||||
|
- pub/fenix/nightly
|
||||||
|
default:
|
||||||
|
- pub/fenix/releases
|
||||||
|
default_locales:
|
||||||
|
- multi
|
||||||
|
tasktype_map:
|
||||||
|
signing: signing
|
||||||
|
platform_names:
|
||||||
|
path_platform: android
|
||||||
|
tools_platform: android
|
||||||
|
filename_platform: android
|
||||||
|
|
||||||
|
default: &default
|
||||||
|
from:
|
||||||
|
- signing
|
||||||
|
all_locales: true
|
||||||
|
description: "TO_BE_OVERRIDDEN"
|
||||||
|
# Hard coded 'multi' locale
|
||||||
|
locale_prefix: '${locale}'
|
||||||
|
source_path_modifier:
|
||||||
|
by-locale:
|
||||||
|
default: '${locale}'
|
||||||
|
multi: ''
|
||||||
|
checksums_path: "TODO"
|
||||||
|
|
||||||
|
mapping:
|
||||||
|
arm64-v8a/target.apk:
|
||||||
|
<<: *default
|
||||||
|
description: "Android package for arm64-v8a"
|
||||||
|
pretty_name: fenix-${version}.${locale}.android-arm64-v8a.apk
|
||||||
|
destinations:
|
||||||
|
- ${version}/android-arm64-v8a
|
||||||
|
armeabi-v7a/target.apk:
|
||||||
|
<<: *default
|
||||||
|
description: "Android package for armeabi-v7a"
|
||||||
|
pretty_name: fenix-${version}.${locale}.android-armeabi-v7a.apk
|
||||||
|
destinations:
|
||||||
|
- ${version}/android-armeabi-v7a
|
||||||
|
x86/target.apk:
|
||||||
|
<<: *default
|
||||||
|
description: "Android package for x86"
|
||||||
|
pretty_name: fenix-${version}.${locale}.android-x86.apk
|
||||||
|
destinations:
|
||||||
|
- ${version}/android-x86
|
||||||
|
x86_64/target.apk:
|
||||||
|
<<: *default
|
||||||
|
description: "Android package for x86_64"
|
||||||
|
pretty_name: fenix-${version}.${locale}.android-x86_64.apk
|
||||||
|
destinations:
|
||||||
|
- ${version}/android-x86_64
|
@ -0,0 +1,103 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
"""
|
||||||
|
Transform the beetmover task into an actual task description.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import absolute_import, print_function, unicode_literals
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from six import text_type, ensure_text
|
||||||
|
|
||||||
|
from taskgraph.transforms.base import TransformSequence
|
||||||
|
from taskgraph.transforms.task import task_description_schema
|
||||||
|
from voluptuous import Any, Optional, Required, Schema
|
||||||
|
|
||||||
|
from fenix_taskgraph.util.scriptworker import generate_beetmover_artifact_map
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
beetmover_description_schema = Schema(
|
||||||
|
{
|
||||||
|
# unique name to describe this beetmover task, defaults to {dep.label}-beetmover
|
||||||
|
Required("name"): text_type,
|
||||||
|
Required("worker"): {"upstream-artifacts": [dict]},
|
||||||
|
# treeherder is allowed here to override any defaults we use for beetmover.
|
||||||
|
Optional("treeherder"): task_description_schema["treeherder"],
|
||||||
|
Optional("attributes"): task_description_schema["attributes"],
|
||||||
|
Optional("dependencies"): task_description_schema["dependencies"],
|
||||||
|
Optional("run-on-tasks-for"): [text_type],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
transforms = TransformSequence()
|
||||||
|
transforms.add_validate(beetmover_description_schema)
|
||||||
|
|
||||||
|
|
||||||
|
@transforms.add
|
||||||
|
def make_task_description(config, tasks):
|
||||||
|
for task in tasks:
|
||||||
|
attributes = task["attributes"]
|
||||||
|
|
||||||
|
label = "beetmover-{}".format(task["name"])
|
||||||
|
description = (
|
||||||
|
"Beetmover submission for build type '{build_type}'".format(
|
||||||
|
build_type=attributes.get("build-type"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if task.get("locale"):
|
||||||
|
attributes["locale"] = task["locale"]
|
||||||
|
|
||||||
|
task = {
|
||||||
|
"label": label,
|
||||||
|
"description": description,
|
||||||
|
"worker-type": "beetmover",
|
||||||
|
"worker": task["worker"],
|
||||||
|
"scopes": [
|
||||||
|
"project:mobile:fenix:releng:beetmover:bucket:dep",
|
||||||
|
"project:mobile:fenix:releng:beetmover:action:direct-push-to-bucket",
|
||||||
|
],
|
||||||
|
"dependencies": task["dependencies"],
|
||||||
|
"attributes": attributes,
|
||||||
|
"run-on-projects": attributes.get("run_on_projects"),
|
||||||
|
"run-on-tasks-for": attributes.get("run_on_tasks_for"),
|
||||||
|
"treeherder": task["treeherder"],
|
||||||
|
}
|
||||||
|
|
||||||
|
yield task
|
||||||
|
|
||||||
|
|
||||||
|
def craft_release_properties(config, task):
|
||||||
|
params = config.params
|
||||||
|
return {
|
||||||
|
"app-name": ensure_text(params["project"]),
|
||||||
|
"app-version": ensure_text(params["version"]),
|
||||||
|
"branch": ensure_text(params["project"]),
|
||||||
|
"build-id": ensure_text(params["moz_build_date"]),
|
||||||
|
"hash-type": "sha512",
|
||||||
|
"platform": "android",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@transforms.add
|
||||||
|
def make_task_worker(config, tasks):
|
||||||
|
for task in tasks:
|
||||||
|
locale = task["attributes"].get("locale")
|
||||||
|
build_type = task["attributes"]["build-type"]
|
||||||
|
|
||||||
|
task["worker"].update(
|
||||||
|
{
|
||||||
|
"implementation": "beetmover",
|
||||||
|
"release-properties": craft_release_properties(config, task),
|
||||||
|
"artifact-map": generate_beetmover_artifact_map(
|
||||||
|
config, task, platform=build_type, locale=locale
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if locale:
|
||||||
|
task["worker"]["locale"] = locale
|
||||||
|
|
||||||
|
yield task
|
@ -0,0 +1,296 @@
|
|||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
from __future__ import absolute_import, print_function, unicode_literals
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import jsone
|
||||||
|
|
||||||
|
from taskgraph.util.memoize import memoize
|
||||||
|
from taskgraph.util.schema import resolve_keyed_by
|
||||||
|
from taskgraph.util.taskcluster import get_artifact_prefix
|
||||||
|
from taskgraph.util.yaml import load_yaml
|
||||||
|
|
||||||
|
cached_load_yaml = memoize(load_yaml)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_beetmover_upstream_artifacts(
|
||||||
|
config, job, platform, locale=None, dependencies=None, **kwargs
|
||||||
|
):
|
||||||
|
"""Generate the upstream artifacts for beetmover, using the artifact map.
|
||||||
|
|
||||||
|
Currently only applies to beetmover tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job (dict): The current job being generated
|
||||||
|
dependencies (list): A list of the job's dependency labels.
|
||||||
|
platform (str): The current build platform
|
||||||
|
locale (str): The current locale being beetmoved.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of dictionaries conforming to the upstream_artifacts spec.
|
||||||
|
"""
|
||||||
|
base_artifact_prefix = get_artifact_prefix(job)
|
||||||
|
resolve_keyed_by(
|
||||||
|
job,
|
||||||
|
"attributes.artifact_map",
|
||||||
|
"artifact map",
|
||||||
|
**{
|
||||||
|
"release-type": config.params["release_type"],
|
||||||
|
"platform": platform,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
map_config = deepcopy(cached_load_yaml(job["attributes"]["artifact_map"]))
|
||||||
|
upstream_artifacts = list()
|
||||||
|
|
||||||
|
if not locale:
|
||||||
|
locales = map_config["default_locales"]
|
||||||
|
elif isinstance(locale, list):
|
||||||
|
locales = locale
|
||||||
|
else:
|
||||||
|
locales = [locale]
|
||||||
|
|
||||||
|
if not dependencies:
|
||||||
|
if job.get("dependencies"):
|
||||||
|
dependencies = job["dependencies"].keys()
|
||||||
|
elif job.get("primary-dependency"):
|
||||||
|
dependencies = [job["primary-dependency"].kind]
|
||||||
|
else:
|
||||||
|
raise Exception("Unsupported type of dependency. Got job: {}".format(job))
|
||||||
|
|
||||||
|
for locale, dep in itertools.product(locales, dependencies):
|
||||||
|
paths = list()
|
||||||
|
|
||||||
|
for filename in map_config["mapping"]:
|
||||||
|
if dep not in map_config["mapping"][filename]["from"]:
|
||||||
|
continue
|
||||||
|
if locale != "multi" and not map_config["mapping"][filename]["all_locales"]:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
"only_for_platforms" in map_config["mapping"][filename]
|
||||||
|
and platform
|
||||||
|
not in map_config["mapping"][filename]["only_for_platforms"]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
"not_for_platforms" in map_config["mapping"][filename]
|
||||||
|
and platform in map_config["mapping"][filename]["not_for_platforms"]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if "partials_only" in map_config["mapping"][filename]:
|
||||||
|
continue
|
||||||
|
# The next time we look at this file it might be a different locale.
|
||||||
|
file_config = deepcopy(map_config["mapping"][filename])
|
||||||
|
resolve_keyed_by(
|
||||||
|
file_config,
|
||||||
|
"source_path_modifier",
|
||||||
|
"source path modifier",
|
||||||
|
locale=locale,
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs["locale"] = locale
|
||||||
|
|
||||||
|
paths.append(
|
||||||
|
os.path.join(
|
||||||
|
base_artifact_prefix,
|
||||||
|
jsone.render(file_config["source_path_modifier"], kwargs),
|
||||||
|
jsone.render(filename, kwargs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if job.get("dependencies") and getattr(
|
||||||
|
job["dependencies"][dep], "release_artifacts", None
|
||||||
|
):
|
||||||
|
paths = [
|
||||||
|
path
|
||||||
|
for path in paths
|
||||||
|
if path in job["dependencies"][dep].release_artifacts
|
||||||
|
]
|
||||||
|
|
||||||
|
if not paths:
|
||||||
|
continue
|
||||||
|
|
||||||
|
upstream_artifacts.append(
|
||||||
|
{
|
||||||
|
"taskId": {"task-reference": "<{}>".format(dep)},
|
||||||
|
"taskType": map_config["tasktype_map"].get(dep),
|
||||||
|
"paths": sorted(paths),
|
||||||
|
"locale": locale,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_artifacts.sort(key=lambda u: u["paths"])
|
||||||
|
return upstream_artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def generate_beetmover_artifact_map(config, job, **kwargs):
|
||||||
|
"""Generate the beetmover artifact map.
|
||||||
|
|
||||||
|
Currently only applies to beetmover tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config (): Current taskgraph configuration.
|
||||||
|
job (dict): The current job being generated
|
||||||
|
Common kwargs:
|
||||||
|
platform (str): The current build platform
|
||||||
|
locale (str): The current locale being beetmoved.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of dictionaries containing source->destination
|
||||||
|
maps for beetmover.
|
||||||
|
"""
|
||||||
|
platform = kwargs.get("platform", "")
|
||||||
|
resolve_keyed_by(
|
||||||
|
job,
|
||||||
|
"attributes.artifact_map",
|
||||||
|
job["label"],
|
||||||
|
**{
|
||||||
|
"release-type": config.params["release_type"],
|
||||||
|
"platform": platform,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
map_config = deepcopy(cached_load_yaml(job["attributes"]["artifact_map"]))
|
||||||
|
base_artifact_prefix = map_config.get(
|
||||||
|
"base_artifact_prefix", get_artifact_prefix(job)
|
||||||
|
)
|
||||||
|
|
||||||
|
artifacts = list()
|
||||||
|
|
||||||
|
dependencies = job["dependencies"].keys()
|
||||||
|
|
||||||
|
if kwargs.get("locale"):
|
||||||
|
if isinstance(kwargs["locale"], list):
|
||||||
|
locales = kwargs["locale"]
|
||||||
|
else:
|
||||||
|
locales = [kwargs["locale"]]
|
||||||
|
else:
|
||||||
|
locales = map_config["default_locales"]
|
||||||
|
|
||||||
|
resolve_keyed_by(
|
||||||
|
map_config,
|
||||||
|
"s3_bucket_paths",
|
||||||
|
job["label"],
|
||||||
|
**{
|
||||||
|
"release-type": config.params['release_type'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for locale, dep in sorted(itertools.product(locales, dependencies)):
|
||||||
|
paths = dict()
|
||||||
|
for filename in map_config["mapping"]:
|
||||||
|
# Relevancy checks
|
||||||
|
if dep not in map_config["mapping"][filename]["from"]:
|
||||||
|
# We don't get this file from this dependency.
|
||||||
|
continue
|
||||||
|
if locale != "multi" and not map_config["mapping"][filename]["all_locales"]:
|
||||||
|
# This locale either doesn't produce or shouldn't upload this file.
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
"only_for_platforms" in map_config["mapping"][filename]
|
||||||
|
and platform
|
||||||
|
not in map_config["mapping"][filename]["only_for_platforms"]
|
||||||
|
):
|
||||||
|
# This platform either doesn't produce or shouldn't upload this file.
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
"not_for_platforms" in map_config["mapping"][filename]
|
||||||
|
and platform in map_config["mapping"][filename]["not_for_platforms"]
|
||||||
|
):
|
||||||
|
# This platform either doesn't produce or shouldn't upload this file.
|
||||||
|
continue
|
||||||
|
if "partials_only" in map_config["mapping"][filename]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# deepcopy because the next time we look at this file the locale will differ.
|
||||||
|
file_config = deepcopy(map_config["mapping"][filename])
|
||||||
|
|
||||||
|
for field in [
|
||||||
|
"destinations",
|
||||||
|
"locale_prefix",
|
||||||
|
"source_path_modifier",
|
||||||
|
"update_balrog_manifest",
|
||||||
|
"pretty_name",
|
||||||
|
"checksums_path",
|
||||||
|
]:
|
||||||
|
resolve_keyed_by(
|
||||||
|
file_config,
|
||||||
|
field,
|
||||||
|
job["label"],
|
||||||
|
locale=locale,
|
||||||
|
path_platform=platform,
|
||||||
|
version=config.params["version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# This format string should ideally be in the configuration file,
|
||||||
|
# but this would mean keeping variable names in sync between code + config.
|
||||||
|
destinations = [
|
||||||
|
"{s3_bucket_path}/{dest_path}/{locale_prefix}/{filename}".format(
|
||||||
|
s3_bucket_path=bucket_path,
|
||||||
|
dest_path=dest_path,
|
||||||
|
locale_prefix=file_config["locale_prefix"],
|
||||||
|
filename=file_config.get("pretty_name", filename),
|
||||||
|
)
|
||||||
|
for dest_path, bucket_path in itertools.product(
|
||||||
|
file_config["destinations"], map_config["s3_bucket_paths"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Creating map entries
|
||||||
|
# Key must be artifact path, to avoid trampling duplicates, such
|
||||||
|
# as public/build/target.apk and public/build/multi/target.apk
|
||||||
|
key = os.path.join(
|
||||||
|
base_artifact_prefix,
|
||||||
|
file_config["source_path_modifier"],
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
paths[key] = {
|
||||||
|
"destinations": destinations,
|
||||||
|
}
|
||||||
|
if file_config.get("checksums_path"):
|
||||||
|
paths[key]["checksums_path"] = file_config["checksums_path"]
|
||||||
|
|
||||||
|
# optional flag: balrog manifest
|
||||||
|
if file_config.get("update_balrog_manifest"):
|
||||||
|
paths[key]["update_balrog_manifest"] = True
|
||||||
|
if file_config.get("balrog_format"):
|
||||||
|
paths[key]["balrog_format"] = file_config["balrog_format"]
|
||||||
|
|
||||||
|
if not paths:
|
||||||
|
# No files for this dependency/locale combination.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Render all variables for the artifact map
|
||||||
|
platforms = deepcopy(map_config.get("platform_names", {}))
|
||||||
|
if platform:
|
||||||
|
for key in platforms.keys():
|
||||||
|
resolve_keyed_by(platforms, key, job["label"], platform=platform)
|
||||||
|
|
||||||
|
upload_date = datetime.fromtimestamp(config.params["build_date"])
|
||||||
|
|
||||||
|
kwargs.update(
|
||||||
|
{
|
||||||
|
"locale": locale,
|
||||||
|
"version": config.params["version"],
|
||||||
|
"branch": config.params["project"],
|
||||||
|
"build_number": config.params["build_date"],
|
||||||
|
"year": upload_date.year,
|
||||||
|
"month": upload_date.strftime("%m"), # zero-pad the month
|
||||||
|
"upload_date": upload_date.strftime("%Y-%m-%d-%H-%M-%S"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
kwargs.update(**platforms)
|
||||||
|
paths = jsone.render(paths, kwargs)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"taskId": {"task-reference": "<{}>".format(dep)},
|
||||||
|
"locale": locale,
|
||||||
|
"paths": paths,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return artifacts
|
Loading…
Reference in New Issue