This repository has been archived on 2026-11-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
platform-ci/platform_ci/plan.py

502 lines
28 KiB
Python

"""Turn a validated configuration and one event into the deterministic execution plan.
The plan is data for the fixed workflow, not generated workflow YAML. It is a pure function
of the configuration and the event: nothing here receives a repository name or owner.
"""
import json
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from fnmatch import fnmatchcase
from typing import NotRequired, TypedDict
from platform_ci.build_tools import BUILD_TOOLS, CONTAINER_EXECUTION, DOCKER_POOL_EXECUTION, DOCKER_PROFILE_ENVIRONMENT, PLATFORMS, POOL_EXECUTION, PROFILE_CONTAINER_OPTIONS, TOOLCHAIN_DISPLAY
from platform_ci.configuration import Component, ProjectConfiguration, branch_matches
from platform_ci.effective_version import effective_version, version_parts, with_version_property
from platform_ci.event_selection import EventFacts, channel_matches, selected
from platform_ci.runtimes import RuntimeCatalog
from platform_ci.workspace import PipelineError, require_commit_hash
PLAN_SCHEMA_VERSION = 1
DELIVERY_NOTE = "This workflow never signs, publishes or deploys and holds no release credential. It lists the requested publications; the separate trusted publisher verifies this run and delivers through its own bindings."
DeclaredVersionReader = Callable[["Target"], str]
"""Reads the declared version of a planned target (from a checkout or from forge facts); injected so this module never imports `versions` (which imports it)."""
ReleaseActivityReader = Callable[[], bool]
"""Answers whether the release commit's history, within the window ending at the SERVER's build start of the run, holds a captured release merge or bump (`effective_version`); injected
and lazy: it reads the forge only when a develop SNAPSHOT needs it, so no plan without that case reads a job or a commit."""
_BRANCH_REFERENCE_PREFIX = "refs/heads/"
_TAG_REFERENCE_PREFIX = "refs/tags/"
class ReportTarget(TypedDict):
"""One declared report in the plan JSON: `format` is `junit` or `file`; `paths` are globs relative to the component directory."""
format: str
paths: list[str]
required: bool
class ImageTarget(TypedDict):
"""One image a target packages from its own verified output: `artifact` names the verified artifact the image may contain."""
artifact: str
dockerfile: str
recipe: str
context: str
target: str
node_version: str
health_port: str
ignore_unfixed: bool
"""The closed scan-policy field: findings with no available fix do not fail the scan; every other HIGH or CRITICAL finding still does."""
class CheckoutTarget(TypedDict):
"""One pinned private dependency fetched before the build: a logical binding, the repository, the exact commit and the path; never a credential."""
binding: str
repository: str
ref: str
path: str
class VersionSourceTarget(TypedDict):
"""The explicit native version source of a `script` component: a component-relative `.properties` file and the key whose literal value is the version."""
file: str
property: str
class Target(TypedDict):
"""One component on one platform: everything a verification job needs, as plain data.
`execution` is `container` (a catalog `image` on the standard pool), `pool` or `pool-docker` (the image on the profile pool `runner`, run by a pool job; only `pool-docker`, which requires the docker profile, mounts the test daemon) or `host` (directly on the `runner`, with an
empty `image`). `requires` lists the runtime profiles; `emulator` (android toolchain script components) selects the `pool` job with the KVM device option and no other
privilege. `environment` is the fixed, non-secret variables the build tool needs.
"""
name: str
display: str
component: str
platform: str
build_tool: str
toolchain: str
toolchain_version: str
execution: str
runner: str
image: str
requires: list[str]
environment: dict[str, str]
directory: str
commands: list[list[str]]
reports: dict[str, ReportTarget]
artifacts: dict[str, list[str]]
images: dict[str, ImageTarget]
"""Images packaged after verification and packaging in the same job from the verified outputs; empty for most targets."""
checkouts: dict[str, CheckoutTarget]
"""Pinned private dependencies the platform fetches (credential only inside that step) before the build; empty for most targets."""
version_source: NotRequired[VersionSourceTarget]
"""Present only when the component declares one: it is then part of this target, so of the config digest, and is read the same way at packaging and through the forge."""
class Verification(TypedDict):
"""Whether configuration requests verification for this event, and why. A requested verification runs exactly once.
When `required` is false the targets are still listed (so the matrix is never empty) but the verify job is expected to skip.
"""
required: bool
reason: str
class PlannedPublication(TypedDict):
"""One requested publication that applies to this event: a component artifact, a logical destination and the eligible channels.
`target` is the verified target whose artifact bundle supplies the files. No address, credential or owner is ever present.
"""
name: str
component: str
artifact: str
format: str
platform: str
target: str
destination: str
channels: list[str]
deploy: str
class Delivery(TypedDict):
"""Release eligibility and requested publications declared by configuration.
`implemented` is false because this workflow itself never delivers; the trusted publisher does. `publications` lists
only the requests whose channels are eligible for this event, so it is empty for pull requests and ineligible pushes.
"""
implemented: bool
eligible_channels: list[str]
publications: list[PlannedPublication]
note: str
class ReuseEligibility(TypedDict):
"""Whether tested bytes of an identical earlier build may replace this run's verification, decided from the event alone.
Eligible for a push or pull request whose verification is required. A pull request may reuse a completed push
only; manual or callable runs always verify. Eligibility is not a decision to reuse;
the reuse step must still prove identical inputs, tree, lineage and bundles, and otherwise the build runs.
"""
eligible: bool
reason: str
class Plan(TypedDict):
"""The complete expected work for one run."""
schema: int
source_revision: str
source_tree: str
reuse: ReuseEligibility
platform_revision: str
event: str
verification: Verification
targets: list[Target]
delivery: Delivery
@dataclass(frozen=True)
class EventContext:
"""The event facts the plan depends on. Repository identity is deliberately absent."""
name: str
ref: str
source_revision: str
platform_revision: str
default_branch: str = ""
"""The repository's default branch as reported by the event; required only for branch pushes."""
source_tree: str = ""
"""The git tree id of `source_revision`; required only to decide reuse, never to build."""
head_branch: str = ""
"""A pull request's head branch (`github.head_ref`), so event selection by release channel sees it; empty for every other event."""
def prepare_plan(project: ProjectConfiguration, event: EventContext, runtimes: RuntimeCatalog, declared_version: DeclaredVersionReader | None = None, release_activity: ReleaseActivityReader | None = None) -> Plan:
"""Expand components into targets and decide from configuration whether this invocation verifies.
Pull requests, manual and callable requests, default branch pushes and release-eligible pushes always verify once.
Other pushes verify only when `verification.push` selects them. The pipeline never guesses whether a pull request
exists, so an unselected push is reported as not requested rather than as covered. Release eligibility is read from
configuration and recorded. Each target's image comes from the runtime catalog; an unsupported or unqualified
toolchain raises before any job exists, requested or not.
"""
require_commit_hash(event.source_revision, "source revision")
require_commit_hash(event.platform_revision, "platform revision")
branch = _pushed_branch(event)
facts = event_facts_of(event)
# the same selection the trusted publisher applies: components and images restricted by channel run only for the events they select
targets = plan_targets(project, runtimes, facts, declared_version, release_activity)
eligible_channels = sorted(channel.name for channel in project.channels if event.name == "push" and channel_matches(channel, facts))
verification = _decide_verification(project, event, branch, eligible_channels)
return Plan(
schema=PLAN_SCHEMA_VERSION,
source_revision=event.source_revision,
source_tree=event.source_tree,
reuse=_reuse_eligibility(event, verification),
platform_revision=event.platform_revision,
event=event.name,
verification=verification,
targets=targets,
delivery=Delivery(implemented=False, eligible_channels=eligible_channels, publications=publication_requests(project, eligible_channels), note=DELIVERY_NOTE),
)
def _reuse_eligibility(event: EventContext, verification: Verification) -> ReuseEligibility:
"""Allow proven completed bytes for required push or PR verification; keep manual and callable runs verifying."""
if event.name not in ("push", "pull_request"):
return ReuseEligibility(eligible=False, reason=f"{event.name} events always verify: they are the original verification")
if not verification["required"]:
return ReuseEligibility(eligible=False, reason="verification is not requested, so there is nothing to replace")
if not event.source_tree:
return ReuseEligibility(eligible=False, reason="the source tree id is unknown, so equality with an earlier build cannot be proven")
return ReuseEligibility(eligible=True, reason="a required push or pull request verification may be replaced by tested bytes of an identical completed build")
def event_facts_of(event: EventContext) -> EventFacts:
"""Return the authoritative facts of the event: its name, the push branch or pull request head branch, the pushed tag and the exact revision."""
branch = _pushed_branch(event) or ""
tag = event.ref.removeprefix(_TAG_REFERENCE_PREFIX) if event.name == "push" and event.ref.startswith(_TAG_REFERENCE_PREFIX) else ""
return EventFacts(name=event.name, branch=branch or event.head_branch, tag=tag, source_revision=event.source_revision)
def plan_targets(
project: ProjectConfiguration,
runtimes: RuntimeCatalog,
facts: EventFacts | None = None,
declared_version: DeclaredVersionReader | None = None,
release_activity: ReleaseActivityReader | None = None,
) -> list[Target]:
"""Expand components and platforms into targets; the same function serves the build and the publisher's equivalence proof.
With `facts`, a component or image its `only_on_channels` / `not_on_channels` do not select for that event is left out (an excluded image also loses
its generated archive and evidence outputs), and a component that opted in to event facts gets the fixed variables. Without `facts` nothing is
filtered and no event variable is set: the trusted publisher and the build must both pass the facts of the event they mean.
UNTESTED. For a push whose matching channel declares an `effective_version` rule, every Maven component the event selects gets the calculated version as
`-D<property>=<version>` on every native command (also when it equals the declared version: the explicit property can activate a profile), so the build, the recorded build inputs and the recomputation by the publisher and the tag planner all see it.
The original (non-push) verification of a Maven component governed by effective-version channels that agree on one property plans that same property with the DECLARED version (`_with_declared_version`),
so an unchanged-version release has the target and `config_digest` of the tested commands while a changed version differs. That needs `declared_version`; without it such events keep the plain commands. A develop SNAPSHOT also needs
`release_activity` (build start and history, both from the forge and bound to ONE run); every caller binds it to the run whose build it plans or recomputes. That needs `declared_version`,
which reads the declared version of an UNMODIFIED target (from the checkout in `prepare`, from the forge in the publisher and tag planner); without it such a plan is refused. The reader is a static
resolver (`versions._pom_version`), the fast-fail before any build: the proof against Maven's own resolution is the publication adapter, which requires every retained POM's coordinates to equal the
recorded version (`delivery/adapters.py::_require_manifest_version`). No Maven process runs and nothing compiles here.
"""
channels = project.channels
targets: list[Target] = []
for component in project.components:
if facts is not None and not selected(component.only_on_channels, component.not_on_channels, channels, facts):
continue
excluded = frozenset(name for name, image in component.images.items() if facts is not None and not selected(image.only_on_channels, image.not_on_channels, channels, facts))
rule = _effective_rule(project, component, facts)
original_property = _verification_version_property(project, component) if rule is None and facts is not None and facts.name != "push" and declared_version is not None else ""
for platform in component.platforms:
target = _target(component, platform, runtimes, facts, excluded)
if rule is not None and facts is not None:
target = _with_effective_version(target, rule, facts, declared_version, release_activity)
elif original_property and declared_version is not None:
target = _with_declared_version(target, original_property, declared_version)
targets.append(target)
return targets
def _effective_rule(project: ProjectConfiguration, component: Component, facts: EventFacts | None) -> tuple[str, str, str] | None:
"""Return the (rule, property) a push applies to this Maven component through the channels the event matches (as the captured calculator ran for every eligible publish kind); two different ones conflict."""
if facts is None or facts.name != "push" or component.build_tool != "maven":
return None
rules = {(channel.effective_version, channel.version_property, channel.rc_baseline) for channel in project.channels if channel.effective_version and channel_matches(channel, facts)}
if len(rules) > 1:
raise PipelineError(f"the event matches release channels with different effective version rules {sorted(rules)}, so component {component.name!r} has no single version")
return next(iter(rules)) if rules else None
def _with_effective_version(target: Target, rule: tuple[str, str, str], facts: EventFacts, declared_version: DeclaredVersionReader | None, release_activity: ReleaseActivityReader | None = None) -> Target:
"""Apply the calculated version to the target's commands, then prove the build tool now resolves exactly that version (never relabelling).
The property is ALWAYS appended, also when the calculated version equals the declared one: a Maven profile may activate on the mere presence of the explicit property, so dropping it is not proven to
preserve the build. The original verification of such a component plans the same explicit property (`_with_declared_version`), so an unchanged version matches those tested commands.
"""
if declared_version is None:
raise PipelineError(f"target {target['name']!r} needs an effective version, but no reader of its declared version was supplied")
if target.get("version_source") is not None:
raise PipelineError(f"target {target['name']!r} reads its version from an explicit version_source, which an effective version property cannot replace")
declared = declared_version(target)
version = effective_version(rule[0], declared, facts.branch, facts.tag, rule[2], release_activity)
applied: Target = {**target, "commands": with_version_property(target["commands"], rule[1], version)}
resolved = declared_version(applied)
if resolved != version:
raise PipelineError(f"target {target['name']!r}: with -D{rule[1]}={version} the project resolves version {resolved!r}, not {version!r}; the version must be declared through that property")
return applied
def _verification_version_property(project: ProjectConfiguration, component: Component) -> str:
"""Return the version property the configured effective-version channels agree on, or "" when none exists.
Release planning applies the configured rule to every selected Maven component, including verification-only components. Original verification uses that same scope.
Channels without an effective-version rule are ignored; conflicting properties are refused.
"""
if component.build_tool != "maven":
return ""
properties = {channel.version_property for channel in project.channels if channel.effective_version}
if len(properties) > 1:
raise PipelineError(f"component {component.name!r} has effective version channels with different version properties {sorted(properties)}, so its verification has no single property to plan")
return next(iter(properties), "")
def _with_declared_version(target: Target, property_name: str, declared_version: DeclaredVersionReader) -> Target:
"""Plan an original (non-push) verification with the same explicit `-D<property>=<declared>` the release build uses, so that an unchanged-version release has identical commands.
The declared version must be in a captured format (as every rule requires for the release) and the project must resolve exactly that version through the property; otherwise the release
could never be built either, and this is refused here, before the verification, rather than recorded as an unreleasable build.
"""
if target.get("version_source") is not None:
raise PipelineError(f"target {target['name']!r} reads its version from an explicit version_source, which an effective version property cannot replace")
declared = declared_version(target)
version_parts(declared)
applied: Target = {**target, "commands": with_version_property(target["commands"], property_name, declared)}
resolved = declared_version(applied)
if resolved != declared:
raise PipelineError(f"target {target['name']!r}: with -D{property_name}={declared} the project resolves version {resolved!r}, not {declared!r}; the version must be declared through that property")
return applied
def publication_requests(project: ProjectConfiguration, eligible_channels: Sequence[str]) -> list[PlannedPublication]:
"""Return the configured publications restricted to the eligible channels; a publication with none of them is not requested."""
requests: list[PlannedPublication] = []
for publication in project.publications:
channels = sorted(set(publication.channels) & set(eligible_channels))
if channels:
target_name = f"{publication.component}-{publication.platform}"
requests.append(
PlannedPublication(
name=publication.name,
component=publication.component,
artifact=publication.artifact,
format=publication.format,
platform=publication.platform,
target=target_name,
destination=publication.destination,
channels=channels,
deploy=publication.deploy,
)
)
return requests
def build_matrix(plan: Plan, execution: str = CONTAINER_EXECUTION) -> dict[str, list[dict[str, str]]]:
"""Return the native job matrix of the targets with one execution kind (`container` by default, `host` or `pool`).
Each entry carries its target as a JSON string so any matrix engine can pass it through. Standard container, pool and host
targets are separate matrices because they run in separate jobs: only the pool job may carry container options and mounts, so a
pool entry also carries the container `options` its runtime profiles add (empty when none).
"""
entries: list[dict[str, str]] = []
for target in plan["targets"]:
if target["execution"] != execution:
continue
entry = {"name": target["name"], "display": target["display"], "image": target["image"], "runner": target["runner"], "target": to_compact_json(target)}
if execution in (POOL_EXECUTION, DOCKER_POOL_EXECUTION):
entry["options"] = pool_container_options(target["requires"])
if target["checkouts"]:
entry["dependencies"] = "true" # the job's checkout-dependencies step runs only for entries that carry this flag
entries.append(entry)
return {"include": entries}
def pool_container_options(profiles: Sequence[str]) -> str:
"""Return the container options the runtime profiles add in the pool job, as one space-separated string (empty when none)."""
return " ".join(option for profile in profiles for option in PROFILE_CONTAINER_OPTIONS.get(profile, ()))
def to_compact_json(value: object) -> str:
"""Serialize deterministically on a single line, suitable for a workflow output."""
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def _pushed_branch(event: EventContext) -> str | None:
"""Return the pushed branch name, or None when the event is not a branch push."""
if event.name != "push" or not event.ref.startswith(_BRANCH_REFERENCE_PREFIX):
return None
return event.ref.removeprefix(_BRANCH_REFERENCE_PREFIX)
def _decide_verification(project: ProjectConfiguration, event: EventContext, branch: str | None, eligible_channels: list[str]) -> Verification:
"""Apply the always-verify rules first, then the configured push selection."""
if event.name != "push":
return Verification(required=True, reason=f"{event.name} event")
if branch is not None:
return _decide_branch_push_verification(project, event, branch, eligible_channels)
if event.ref.startswith(_TAG_REFERENCE_PREFIX):
tag = event.ref.removeprefix(_TAG_REFERENCE_PREFIX)
if eligible_channels:
return Verification(required=True, reason=f"release-eligible tag push (channels: {', '.join(eligible_channels)})")
if any(fnmatchcase(tag, pattern) for pattern in project.push.tags):
return Verification(required=True, reason="tag push selected by verification.push.tags")
return Verification(required=False, reason=f"push of tag {tag!r}, which verification.push.tags does not select")
raise PipelineError(f"push ref {event.ref!r} is neither a branch nor a tag; the pipeline cannot decide whether to verify it")
def _decide_branch_push_verification(project: ProjectConfiguration, event: EventContext, branch: str, eligible_channels: list[str]) -> Verification:
"""Decide verification for a push to a branch: default branch, release-eligible, or selected by configuration."""
if not event.default_branch:
raise PipelineError("default branch metadata (CI_DEFAULT_BRANCH) is required to decide verification for a branch push")
if branch == event.default_branch:
return Verification(required=True, reason="push to the default branch")
if eligible_channels:
return Verification(required=True, reason=f"release-eligible push (channels: {', '.join(eligible_channels)})")
if branch_matches(project.push.branches, branch):
return Verification(required=True, reason="push to a branch selected by verification.push.branches")
return Verification(required=False, reason=f"push to branch {branch!r}, which verification.push.branches does not select")
def _target(component: Component, platform: str, runtimes: RuntimeCatalog, facts: EventFacts | None = None, excluded_images: frozenset[str] = frozenset()) -> Target:
"""Expand one component and platform into a target with tokenized native commands and its qualified runtime.
The runtime is resolved from the catalog by toolchain, version, platform and profiles alone; an unqualified one raises here.
"""
tool = BUILD_TOOLS[component.build_tool]
toolchain = component.toolchain or tool.toolchain
runtime = runtimes.resolve_runtime(toolchain, component.toolchain_version, platform, component.requires)
platform_execution = PLATFORMS[platform].execution
execution = platform_execution
if platform_execution == CONTAINER_EXECUTION and component.requires:
execution = DOCKER_POOL_EXECUTION if "docker" in component.requires else POOL_EXECUTION
profiles = f" / {'+'.join(component.requires)}" if component.requires else ""
environment = dict(tool.environment) | (dict(tool.container_environment) if platform_execution == CONTAINER_EXECUTION else {})
if execution == DOCKER_POOL_EXECUTION:
environment |= dict(DOCKER_PROFILE_ENVIRONMENT)
if component.event_facts and facts is not None:
# fixed, non-secret and taken from the authoritative event, not the runner: part of the target, so of the build-input identity
environment |= {"CI_SOURCE_REVISION": facts.source_revision, "CI_EVENT_NAME": facts.name, "CI_REF_NAME": facts.ref_name, "CI_REF_TYPE": facts.ref_type}
generated = {f"image-{name}" for name in excluded_images} | {f"image-{name}-evidence" for name in excluded_images}
target = Target(
name=f"{component.name}-{platform}",
display=f"{component.name} / {PLATFORMS[platform].display} / {TOOLCHAIN_DISPLAY[toolchain]} {component.toolchain_version}{profiles}",
component=component.name,
platform=platform,
build_tool=component.build_tool,
toolchain=toolchain,
toolchain_version=component.toolchain_version,
execution=execution,
runner=runtime.runner,
image=runtime.image,
requires=list(component.requires),
environment=environment,
directory=component.directory,
commands=_native_commands(component),
reports={name: report for name, report in _report_targets(component).items() if name not in generated},
artifacts={name: list(paths) for name, paths in component.artifacts.items() if name not in generated},
images={
name: ImageTarget(
artifact=image.artifact,
dockerfile=image.dockerfile,
recipe=image.recipe,
context=image.context,
target=image.target,
node_version=image.node_version,
health_port=image.health_port,
ignore_unfixed=image.ignore_unfixed,
)
for name, image in component.images.items()
if name not in excluded_images
},
checkouts={checkout.name: CheckoutTarget(binding=checkout.binding, repository=checkout.repository, ref=checkout.ref, path=checkout.path) for checkout in component.checkouts},
)
if component.version_source is not None:
target["version_source"] = VersionSourceTarget(file=component.version_source.file, property=component.version_source.property)
return target
def has_execution(plan: Plan, execution: str) -> bool:
"""Return whether any planned target runs with the given execution kind (`container` or `host`)."""
return any(target["execution"] == execution for target in plan["targets"])
def _native_commands(component: Component) -> list[list[str]]:
"""Return the native commands as separate tokens: the tool's install commands first, then the declared commands."""
tool = BUILD_TOOLS[component.build_tool]
if tool.helper_subcommands:
return [list(command) for command in component.build_commands]
if tool.subcommands:
programs = dict(tool.subcommands)
return [list(programs[command[0]]) for command in component.build_commands]
if tool.program_in_arguments:
return [list(command) for command in component.build_commands]
return [[tool.executable, *command] for command in (*tool.install_commands, *component.build_commands)]
def _report_targets(component: Component) -> dict[str, ReportTarget]:
"""Return each report as plain JSON data: its format, its globs and whether a successful build must produce it."""
return {name: ReportTarget(format=report.format, paths=list(report.paths), required=report.required) for name, report in component.reports.items()}