Skip to content
Merged
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
19 changes: 16 additions & 3 deletions .github/workflows/ci_nightly.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
# Runs on a nightly schedule (and optionally via manual dispatch).
#
# Executes the full CI matrix: stability (latest tags) and frontier
# (latest branches). Both runs file GitHub issues with their results
# unless manually suppressed via workflow_dispatch inputs.
# Executes the full CI matrix: stability (latest tags), frontier
# (latest branches), and manifest-declared mixed profiles. Runs file GitHub
# issues with their results unless manually suppressed via workflow_dispatch
# inputs.

name: CI (Nightly)

Expand Down Expand Up @@ -35,6 +36,18 @@ jobs:
- name: frontier
issue_label: scenarios-run-frontier
issue_title: "FOC Devnet scenarios run report (frontier)"
- name: stability-frontier-lotus
issue_label: scenarios-run-stability-frontier-lotus
issue_title: "FOC Devnet scenarios run report (stability-frontier-lotus)"
- name: stability-frontier-curio
issue_label: scenarios-run-stability-frontier-curio
issue_title: "FOC Devnet scenarios run report (stability-frontier-curio)"
- name: stability-frontier-filecoin-services
issue_label: scenarios-run-stability-frontier-filecoin-services
issue_title: "FOC Devnet scenarios run report (stability-frontier-filecoin-services)"
- name: stability-frontier-pdp
issue_label: scenarios-run-stability-frontier-pdp
issue_title: "FOC Devnet scenarios run report (stability-frontier-pdp)"
uses: ./.github/workflows/ci_run.yml
with:
name: ${{ matrix.name }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ci_run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Reusable workflow: a single foc-devnet CI run.
#
# Called by ci_pull_request.yml (default config, no reporting) and
# ci_nightly.yml (stability / frontier matrix, issue reporting enabled).
# ci_nightly.yml (manifest-declared nightly profiles, issue reporting enabled).
#
# The dependency profile controls the versions of compatibility-sensitive
# server and client components used by the run.
Expand All @@ -13,11 +13,11 @@ on:
workflow_call:
inputs:
name:
description: 'Human-readable run name (e.g. default, stability, frontier)'
description: 'Human-readable run name (e.g. default, stability, frontier, stability-frontier-curio)'
required: true
type: string
profile:
description: 'Dependency profile: default, stability, or frontier'
description: 'Dependency profile declared in ci/dependency-profiles.json'
required: true
type: string
enable_reporting:
Expand Down
18 changes: 11 additions & 7 deletions README_ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -1333,10 +1333,14 @@ Scenarios run automatically in CI after the devnet starts. On nightly runs (or m
CI resolves compatibility-sensitive dependencies from `ci/dependency-profiles.json`.
Pull requests use the pinned `default` profile, while nightly `stability` runs use
the latest final releases and nightly `frontier` runs pin current development
branch heads to immutable commits. In `stability`, PDP comes from the stable
filecoin-services checkout's bundled submodule; in `frontier`, PDP is pinned as
an independent repo. The resolved metadata path is exposed to scenarios as
`CI_DEPENDENCY_METADATA`; Synapse SDK and filecoin-pin also receive their exact
source, version/ref, and commit through `SYNAPSE_SDK_*` and `FILECOIN_PIN_*`
environment variables. PDP receives `PDP_SOURCE`, `PDP_REF`, and `PDP_COMMIT`
when dependency profiles are resolved.
branch heads to immutable commits. Nightly CI also runs manifest-declared mixed
profiles such as `stability-frontier-curio`, where all dependencies come from
`stability` except the named component, which comes from `frontier`. In
`stability`, PDP is pinned to the git submodule bundled with the selected
filecoin-services stability tag; in `frontier`, PDP is pinned as an independent
repo. Mixed profiles that override filecoin-services keep that PDP pin instead
of implicitly taking the overridden filecoin-services submodule. The resolved
metadata path is exposed to scenarios as `CI_DEPENDENCY_METADATA`; Synapse SDK
and filecoin-pin also receive their exact source, version/ref, and commit
through `SYNAPSE_SDK_*` and `FILECOIN_PIN_*` environment variables. PDP receives
`PDP_SOURCE`, `PDP_REF`, and `PDP_COMMIT` when dependency profiles are resolved.
54 changes: 51 additions & 3 deletions ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,42 @@ Its resolver is located in `scripts/resolve-ci-dependencies.py`.

## Profiles

The manifest currently supports three profiles:
The manifest declares valid profiles in its top-level `profiles` object:

- `default`: used by PR CI, a known-working set of client versions.
- `stability`: used by nightly CI to test stable releases.
- `frontier`: used by nightly CI to test branch heads.
- `stability-frontier-lotus`: used by nightly CI to test stable releases
except Lotus, which is resolved from `frontier`.
- `stability-frontier-curio`: used by nightly CI to test stable releases
except Curio, which is resolved from `frontier`.
- `stability-frontier-filecoin-services`: used by nightly CI to test stable
releases except filecoin-services, which is resolved from `frontier`.
- `stability-frontier-pdp`: used by nightly CI to test stable releases except
PDP, which is resolved from `frontier`.

Each component must define a selection for every component profile referenced by
the top-level profile definitions. Today those component selections are
`default`, `stability`, and `frontier`.

Top-level profile definitions have a `base` component profile and can override
specific components:

Each component must define a selection for each profile.
```json
{
"stability-frontier-curio": {
"base": "stability",
"components": {
"curio": "frontier"
}
}
}
```

In that example, Curio resolves from its `frontier` selection while every other
component resolves from `stability`. A profile is valid only if it is explicitly
declared in `profiles`; for example, `stability-frontier-filecoin-pin` does not
exist unless added there.

## Component Fields

Expand All @@ -20,7 +49,7 @@ Top-level component fields:
- `repository`: Git repository URL.
- `npm_package`: npm package name, for components that are resolved through npm
metadata.
- `default`, `stability`, `frontier`: profile selections.
- `default`, `stability`, `frontier`: component profile selections.

Profile selections always have a `strategy`. Some strategies require additional
fields.
Expand Down Expand Up @@ -91,6 +120,25 @@ By default, pattern selections exclude prerelease tags such as `-rc`, `-alpha`,
}
```

### `git_submodule`

Resolve a git submodule gitlink from a tag or tag pattern in another repository.

```json
{
"strategy": "git_submodule",
"repository": "https://github.com/FilOzone/filecoin-services.git",
"tag": "v*",
"path": "service_contracts/lib/pdp"
}
```

The resolver first resolves `repository` and `tag` with the same rules as
`git_tag`, then reads `path` from that tree and records the submodule gitlink SHA
as the selected component commit. PDP uses this to pin the same bundled PDP
gitlink as the selected filecoin-services stability tag, even in mixed profiles
that override filecoin-services itself.

### `npm_version`

Resolve an npm version, range, or dist-tag to a concrete package version.
Expand Down
42 changes: 40 additions & 2 deletions ci/dependency-profiles.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
{
"schema_version": 1,
"schema_version": 2,
"profiles": {
"default": {
"base": "default"
},
"stability": {
"base": "stability"
},
"frontier": {
"base": "frontier"
},
"stability-frontier-lotus": {
"base": "stability",
"components": {
"lotus": "frontier"
}
},
"stability-frontier-curio": {
"base": "stability",
"components": {
"curio": "frontier"
}
},
"stability-frontier-filecoin-services": {
"base": "stability",
"components": {
"filecoin-services": "frontier"
Comment thread
rjan90 marked this conversation as resolved.
}
},
"stability-frontier-pdp": {
"base": "stability",
"components": {
"pdp": "frontier"
}
}
},
"components": {
"lotus": {
"repository": "https://github.com/filecoin-project/lotus.git",
Expand Down Expand Up @@ -49,7 +84,10 @@
"strategy": "config_default"
},
"stability": {
"strategy": "config_default"
"strategy": "git_submodule",
"repository": "https://github.com/FilOzone/filecoin-services.git",
"tag": "v*",
"path": "service_contracts/lib/pdp"
},
Comment thread
galargh marked this conversation as resolved.
"frontier": {
"strategy": "git_branch",
Expand Down
122 changes: 116 additions & 6 deletions scripts/resolve-ci-dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
import re
import shlex
import subprocess
import tempfile
from pathlib import Path

PROFILES = {"default", "stability", "frontier"}
MANIFEST_SCHEMA_VERSION = 2
INIT_COMPONENT_FLAGS = {
"lotus": "--lotus",
"curio": "--curio",
Expand Down Expand Up @@ -45,19 +46,77 @@ def load_manifest(path: Path) -> dict:
f"Cannot load dependency manifest {path}: {error}"
) from error

if manifest.get("schema_version") != 1:
raise ResolutionError("Dependency manifest schema_version must be 1")
if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
raise ResolutionError(
f"Dependency manifest schema_version must be {MANIFEST_SCHEMA_VERSION}"
)
components = manifest.get("components")
if not isinstance(components, dict):
raise ResolutionError("Dependency manifest must contain a components object")
profiles = manifest.get("profiles")
if not isinstance(profiles, dict):
raise ResolutionError("Dependency manifest must contain a profiles object")

required = set(INIT_COMPONENT_FLAGS) | {"synapse-sdk", "filecoin-pin"}
missing = sorted(required - set(components))
if missing:
raise ResolutionError(f"Dependency manifest is missing: {', '.join(missing)}")
validate_profiles(profiles, components)
return manifest


def validate_profiles(profiles: dict, components: dict) -> None:
for profile_name, profile in profiles.items():
if not isinstance(profile_name, str) or not profile_name:
raise ResolutionError("Profile names must be non-empty strings")
if not isinstance(profile, dict):
raise ResolutionError(f"Profile {profile_name!r} must be an object")

base = profile.get("base")
if not isinstance(base, str) or not base:
raise ResolutionError(f"Profile {profile_name!r} base must be a string")

component_overrides = profile.get("components", {})
if not isinstance(component_overrides, dict):
raise ResolutionError(
f"Profile {profile_name!r} components must be an object"
)

unknown_components = sorted(set(component_overrides) - set(components))
if unknown_components:
raise ResolutionError(
f"Profile {profile_name!r} references unknown components: "
f"{', '.join(unknown_components)}"
)

for component_name, component in components.items():
selection_profile = component_overrides.get(component_name, base)
if not isinstance(selection_profile, str) or not selection_profile:
raise ResolutionError(
f"Profile {profile_name!r} selection for {component_name!r} "
"must be a string"
)
if selection_profile not in component:
raise ResolutionError(
f"Profile {profile_name!r} selects {selection_profile!r} for "
f"{component_name}, but that component has no such selection"
)


def component_profile_map(manifest: dict, profile_name: str) -> dict[str, str]:
profiles = manifest["profiles"]
if profile_name not in profiles:
raise ResolutionError(f"Unknown profile {profile_name!r}")

profile = profiles[profile_name]
base = profile["base"]
component_overrides = profile.get("components", {})
return {
component_name: component_overrides.get(component_name, base)
for component_name in manifest["components"]
}


def parse_ls_remote(output: str) -> list[tuple[str, str]]:
refs = []
for line in output.splitlines():
Expand Down Expand Up @@ -187,6 +246,27 @@ def npm_metadata(package: str, version: str, runner=run_command) -> dict:
return {"version": resolved_version, "gitHead": git_head}


def read_gitlink(repository: str, commit: str, path: str, runner=run_command) -> str:
with tempfile.TemporaryDirectory(prefix="foc-devnet-ci-deps-") as directory:
repo_dir = Path(directory) / "repo"
runner(["git", "-C", directory, "init", "repo"])
runner(["git", "-C", str(repo_dir), "remote", "add", "origin", repository])
runner(["git", "-C", str(repo_dir), "fetch", "--depth=1", "origin", commit])
output = runner(["git", "-C", str(repo_dir), "ls-tree", "FETCH_HEAD", path])

fields = output.split()
if len(fields) < 4 or fields[0] != "160000" or fields[1] != "commit":
raise ResolutionError(
f"{path} in {repository}@{commit} is not a git submodule gitlink"
)
gitlink = fields[2]
if not COMMIT_RE.fullmatch(gitlink):
raise ResolutionError(
f"{path} in {repository}@{commit} has invalid gitlink SHA {gitlink!r}"
)
return gitlink


def validate_overrides(name: str, strategy: str, overrides) -> dict:
if overrides is None:
return {}
Expand Down Expand Up @@ -226,6 +306,7 @@ def resolve_component(
resolved = {
"name": name,
"repository": repository,
"selection_profile": profile,
"strategy": strategy,
}

Expand All @@ -251,6 +332,36 @@ def resolve_component(
branch = selection["branch"]
commit = resolve_ref(repository, f"refs/heads/{branch}", runner)
resolved.update(source="git", ref_type="branch", ref=branch, commit=commit)
elif strategy == "git_submodule":
parent_repository = selection["repository"]
tag = selection["tag"]
path = selection["path"]
if not isinstance(path, str) or not path:
raise ResolutionError(f"{name} git_submodule path must be a string")
if path.startswith("/") or ".." in Path(path).parts:
raise ResolutionError(f"{name} git_submodule path must be relative")
if any(char in tag for char in "*?["):
include_prereleases = selection.get("include_prereleases", False)
if not isinstance(include_prereleases, bool):
raise ResolutionError(f"{name} include_prereleases must be a boolean")
output = runner(["git", "ls-remote", "--tags", parent_repository, tag])
tag, parent_commit = select_latest_tag(output, tag, include_prereleases)
else:
parent_commit = resolve_tag(parent_repository, tag, runner)
commit = read_gitlink(parent_repository, parent_commit, path, runner)
resolved.update(
source="git_submodule",
ref_type="commit",
ref=commit,
commit=commit,
submodule_from={
"repository": parent_repository,
"ref_type": "tag",
"ref": tag,
"commit": parent_commit,
"path": path,
},
)
elif strategy == "npm_version":
requested = selection["version"]
data = npm_metadata(component["npm_package"], requested, runner)
Expand Down Expand Up @@ -314,11 +425,10 @@ def scenario_environment(metadata_path: Path, components: dict) -> dict:


def resolve(args) -> None:
if args.profile not in PROFILES:
raise ResolutionError(f"Unknown profile {args.profile!r}")
manifest = load_manifest(args.manifest)
component_profiles = component_profile_map(manifest, args.profile)
components = {
name: resolve_component(name, component, args.profile)
name: resolve_component(name, component, component_profiles[name], run_command)
for name, component in manifest["components"].items()
}
metadata = {
Expand Down
Loading