Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/mozilla_taskgraph/transforms/build_signing.py
Original file line number Diff line number Diff line change
@@ -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": ["<locale>/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.<trust-domain>.v2.<project>...``
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
Empty file added test/transforms/__init__.py
Empty file.
123 changes: 123 additions & 0 deletions test/transforms/test_build_signing.py
Original file line number Diff line number Diff line change
@@ -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": "<build>"},
"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": "<mac-notarization>"
}