diff --git a/src/mozilla_taskgraph/transforms/build_signing.py b/src/mozilla_taskgraph/transforms/build_signing.py new file mode 100644 index 0000000..6e1b28c --- /dev/null +++ b/src/mozilla_taskgraph/transforms/build_signing.py @@ -0,0 +1,105 @@ +# 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/. +""" +Product-neutral build-signing transforms. + +These shape a signing task from its primary dependency: they derive the signing +index routes and turn a set of signing specs into the ``upstream-artifacts`` +that the ``scriptworker-signing`` payload builder consumes. + +The decision of *which* artifacts and formats to sign is product-specific (it +depends on platforms, locales, installer variants, ...), so it is NOT made +here. An earlier, project-specific transform is expected to populate +``task["signing-artifacts"]`` with a list of specs, each of the form:: + + {"paths": ["/target.zip", ...], "formats": ["...", ...]} +""" + +from collections.abc import Iterator +from typing import Optional + +from taskgraph.transforms.base import TransformConfig, TransformSequence +from taskgraph.util.dependencies import get_primary_dependency +from taskgraph.util.schema import Schema + +from mozilla_taskgraph.util.attributes import copy_attributes_from_dependent_job + + +class SigningArtifactSchema(Schema, kw_only=True): + # Paths, relative to the dependency's artifact prefix, of the artifacts to sign. + paths: list[str] + # Signing formats to apply to each of those paths. + formats: list[str] + + +class BuildSigningSchema(Schema, forbid_unknown_fields=False, kw_only=True): + # Specs of the artifacts to sign. A project with nothing to sign for a given + # task should say so explicitly with an empty list. + signing_artifacts: list[SigningArtifactSchema] + # Whether to mirror the dependency's index routes. Defaults to True. + enable_signing_routes: Optional[bool] = None + + +transforms = TransformSequence() +transforms.add_validate(BuildSigningSchema) + + +@transforms.add +def add_signed_routes(config: TransformConfig, tasks: Iterator[dict]): + """Mirror the primary dependency's index routes, inserting a ``signed`` + component after the project. + + Index routes follow taskgraph's ``index..v2....`` + layout, so the prefix is derived rather than configured. Deciding *which* + tasks deserve signed routes is project policy: filter them out in an earlier + transform, or set ``enable-signing-routes`` to False. + """ + route_prefix = f"index.{config.graph_config['trust-domain']}.v2" + + for task in tasks: + dep_task = get_primary_dependency(config, task) + enable_signing_routes = task.pop("enable-signing-routes", True) + + task["routes"] = [] + if enable_signing_routes: + for route in dep_task.task.get("routes", []): + if not route.startswith(f"{route_prefix}."): + continue + project, _, rest = route[len(route_prefix) + 1 :].partition(".") + task["routes"].append(f"{route_prefix}.{project}.signed.{rest}") + + yield task + + +def _artifact_task_type(dep_kind): + """Notarization dependencies run on scriptworker; everything else is a build.""" + return "scriptworker" if "notarization" in dep_kind else "build" + + +@transforms.add +def define_upstream_artifacts(config: TransformConfig, tasks: Iterator[dict]): + """Copy the curated attributes from the primary dependency and shape the + project-provided ``signing-artifacts`` specs into ``upstream-artifacts``.""" + for task in tasks: + dep_task = get_primary_dependency(config, task) + + attributes = task.setdefault("attributes", {}) + attributes.update(copy_attributes_from_dependent_job(dep_task)) + attributes["signed"] = True + + specs = task.pop("signing-artifacts") + task_ref = {"task-reference": f"<{dep_task.kind}>"} + task_type = _artifact_task_type(dep_task.kind) + + task["upstream-artifacts"] = [ + { + "taskId": task_ref, + "taskType": task_type, + "paths": spec["paths"], + "formats": spec["formats"], + } + for spec in specs + ] + + yield task diff --git a/test/transforms/__init__.py b/test/transforms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/transforms/test_build_signing.py b/test/transforms/test_build_signing.py new file mode 100644 index 0000000..86bbda1 --- /dev/null +++ b/test/transforms/test_build_signing.py @@ -0,0 +1,123 @@ +import pytest +from taskgraph.util.schema import SchemaValidationError + +from mozilla_taskgraph.transforms.build_signing import ( + add_signed_routes, + define_upstream_artifacts, +) +from mozilla_taskgraph.transforms.build_signing import ( + transforms as build_signing_transforms, +) + +from ..conftest import make_task + + +def _dep(routes=None, attributes=None, kind="build"): + return make_task( + "dep-label", + kind=kind, + task_def={"routes": routes or []}, + attributes=attributes or {}, + ) + + +def _task(dep, **extra): + task = {"attributes": {"primary-dependency-label": dep.label}} + task.update(extra) + return task + + +def test_add_signed_routes(make_transform_config): + # The test graph config uses `trust-domain: test`. + dep = _dep( + routes=[ + "index.test.v2.mozilla-central.latest.firefox.win64", + "index.other.v2.mozilla-central.latest.firefox.win64", + "tc-treeherder.v2.mozilla-central.abcdef", + ] + ) + config = make_transform_config(kind_dependencies_tasks={dep.label: dep}) + + [task] = list(add_signed_routes(config, [_task(dep)])) + + # Only this trust-domain's index route is mirrored, with `.signed` inserted + # after the project; other trust domains and non-index routes are ignored. + assert task["routes"] == [ + "index.test.v2.mozilla-central.signed.latest.firefox.win64" + ] + + +def test_add_signed_routes_disabled(make_transform_config): + dep = _dep(routes=["index.test.v2.mozilla-central.latest.firefox.win64"]) + config = make_transform_config(kind_dependencies_tasks={dep.label: dep}) + + [task] = list( + add_signed_routes(config, [_task(dep, **{"enable-signing-routes": False})]) + ) + assert task["routes"] == [] + + +def test_define_upstream_artifacts(make_transform_config): + dep = _dep(attributes={"build_platform": "win64-shippable", "shippable": True}) + config = make_transform_config(kind_dependencies_tasks={dep.label: dep}) + + task = _task( + dep, + **{ + "signing-artifacts": [ + { + "paths": ["public/build/target.zip"], + "formats": ["autograph_authenticode"], + } + ] + }, + ) + [task] = list(define_upstream_artifacts(config, [task])) + + assert task["upstream-artifacts"] == [ + { + "taskId": {"task-reference": ""}, + "taskType": "build", + "paths": ["public/build/target.zip"], + "formats": ["autograph_authenticode"], + } + ] + # Curated attributes copied from the dependency, plus signed marker. + assert task["attributes"]["build_platform"] == "win64-shippable" + assert task["attributes"]["shippable"] is True + assert task["attributes"]["signed"] is True + + +def test_signing_artifacts_is_required(make_transform_config): + """A project that forgets to populate the key gets an error, not a signing + task with an empty payload.""" + dep = _dep() + config = make_transform_config(kind_dependencies_tasks={dep.label: dep}) + + with pytest.raises(SchemaValidationError): + list(build_signing_transforms(config, [_task(dep)])) + + +def test_signing_artifacts_rejects_malformed_spec(make_transform_config): + dep = _dep() + config = make_transform_config(kind_dependencies_tasks={dep.label: dep}) + task = _task(dep, **{"signing-artifacts": [{"paths": ["a"], "format": ["b"]}]}) + + with pytest.raises(SchemaValidationError): + list(build_signing_transforms(config, [task])) + + +def test_define_upstream_artifacts_notarization_task_type(make_transform_config): + dep = _dep(attributes={"build_platform": "macosx64"}, kind="mac-notarization") + config = make_transform_config(kind_dependencies_tasks={dep.label: dep}) + + task = _task( + dep, + **{"signing-artifacts": [{"paths": ["a/target.dmg"], "formats": ["apple"]}]}, + ) + [task] = list(define_upstream_artifacts(config, [task])) + + assert task["upstream-artifacts"][0]["taskType"] == "scriptworker" + assert task["upstream-artifacts"][0]["taskId"] == { + "task-reference": "" + }