Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .github/actions/build-fixtures/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ inputs:
evm_ref:
description: "Override the t8n tool branch / tag / commit"
default: ""
upload:
description: "Upload the filled fixtures. Set to false to rehearse a fill and discard its output."
default: "true"
runs:
using: "composite"
steps:
Expand Down Expand Up @@ -77,11 +80,12 @@ runs:
exit "$EXIT_CODE"
fi
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: inputs.upload == 'true'
with:
name: fixtures_${{ inputs.release_name }}
path: fixtures_${{ inputs.release_name }}.tar.gz
- name: Upload fixture directory (split)
if: inputs.split_label != ''
if: inputs.upload == 'true' && inputs.split_label != ''
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: fixtures__${{ inputs.split_label }}
Expand Down
9 changes: 4 additions & 5 deletions .github/configs/feature.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
# Unless filling for special features, all features should fill for previous forks (starting from Frontier) too
mainnet:
evm-type: eels
fill-params: --until=BPO4 --generate-all-formats

monad:
evm-type: eels
fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=143 -k "not invalid_header"
Expand All @@ -11,3 +6,7 @@ monad_runloop:
evm-type: eels
# Like `monad`, but `--monad-runloop` and eestnet chain id `30143`
fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=30143 --monad-runloop -k "not invalid_header"

monad_eip8246:
evm-type: eels
fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header"
124 changes: 124 additions & 0 deletions .github/scripts/check_release_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "pyyaml",
# ]
# ///
"""
Build the job matrix for a fixture release rehearsal.

Usage: `check_release_matrix.py [features] [branch]`, where `features`
is an optional comma- or space-separated subset of the feature names in
`.github/configs/feature.yaml`.

With no `features`, an EIP branch rehearses the feature it releases for
its own EIP and nothing else, and every other branch rehearses every
feature. Either way a branch that adds a feature is covered without
touching the workflow.

Reuse `generate_build_matrix.py` so a rehearsal fills exactly what the
release fills, then flatten the per-feature matrices into the single
`fill_matrix` a `strategy.matrix` consumes.
"""

import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from generate_build_matrix import ( # noqa: E402
FEATURE_CONFIG,
FORK_RANGES_CONFIG,
build_matrix,
fail,
load_config,
)

# EIP branches follow `eips/<fork>/eip-<n>`, with `+` joining the EIP
# numbers a combined branch carries, e.g. `eips/amsterdam/eip-2345+3456`.
EIP_BRANCH_RE = re.compile(r"^eips/[^/]+/eip-([0-9]+(?:\+[0-9]+)*)$")


def eip_features(defined: list[str], branch: str) -> list[str]:
"""
Return the features *branch* releases for its own EIPs.

An EIP branch names its feature after the EIPs it carries, so
`eips/monad_next/eip-7997` releases `monad_eip7997` and
`eips/amsterdam/eip-2345+3456` releases `monad_eip2345+3456`. The
numbers must match in full: a combined branch does not claim the
feature of either EIP on its own, and neither claims the combined
one. Return an empty list for any other branch, and for an EIP
branch that has not declared a feature of its own yet.
"""
match = EIP_BRANCH_RE.match(branch)
if not match:
return []
numbers = re.compile(rf"eip{re.escape(match.group(1))}(?![0-9+])")
return [name for name in defined if numbers.search(name)]


def defined_features(config: dict) -> list[str]:
"""Return every feature name in `feature.yaml`, in config order."""
return [
name for name, feature in config.items() if isinstance(feature, dict)
]


def select_features(config: dict, requested: str, branch: str) -> list[str]:
"""
Narrow the rehearsal to the requested features.

An empty request falls back to the features *branch* releases for
its own EIPs, then to every feature: this fork releases all of
them. An unknown name fails the run rather than silently
rehearsing less than was asked for.
"""
defined = defined_features(config)
names = [name for name in requested.replace(",", " ").split() if name]
if names:
unknown = [name for name in names if name not in defined]
if unknown:
fail(
f"unknown feature(s) {', '.join(unknown)}; "
f"{FEATURE_CONFIG} defines {', '.join(defined)}"
)
return names
from_branch = eip_features(defined, branch)
if from_branch:
print(
f"Branch '{branch}' releases {', '.join(from_branch)}; "
"rehearsing only that.",
file=sys.stderr,
)
return from_branch
return defined


def main() -> None:
"""Print the rehearsal's feature list and fill matrix to stdout."""
requested = sys.argv[1] if len(sys.argv) > 1 else ""
branch = sys.argv[2] if len(sys.argv) > 2 else ""

config = load_config(FEATURE_CONFIG)
fork_ranges = load_config(FORK_RANGES_CONFIG) or []

features = select_features(config, requested, branch)
if not features:
fail(f"{FEATURE_CONFIG} defines no feature")

matrix: list[dict] = []
for name in features:
entries, _ = build_matrix(config[name], name, fork_ranges)
matrix.extend(entries)

print(f"features={json.dumps(features)}")
print(f"fill_matrix={json.dumps(matrix)}")


if __name__ == "__main__":
main()
174 changes: 174 additions & 0 deletions .github/workflows/check_release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
name: Check Fixture Release

run-name: ${{ github.event_name == 'workflow_dispatch' && format('Check Fixture Release ({0}) {1}', inputs.depth, inputs.features) || 'Check Fixture Release' }}

# Rehearse the fixture releases this fork ships, so a fill that a change
# breaks surfaces on the branch that broke it rather than on the next
# release attempt.
#
# Two tiers, both running on every pull request and on pushes to the
# branches that release in parallel with the fork branch,
# `from-upstream` and `eips/**`.
#
# `collect` is cheap: it reads the features the branch releases out of
# `.github/configs/feature.yaml`, builds the release's job matrix
# from it and collects its tests, catching malformed feature entries,
# import errors and parametrization errors in a few minutes. It gates
# `fill`, which is comprehensive: `fill` runs the very fill the release
# runs, via the release's own action, and is the only tier that catches
# a test that fills wrong. A manual dispatch can ask for `collect`
# alone, and can narrow the run to a subset of the features.

on:
push:
branches:
- from-upstream
- "eips/**"
paths-ignore:
- "**.md"
- "LICENSE*"
- ".gitignore"
- ".vscode/**"
- "whitelist.txt"
- "docs/**"
- "mkdocs.yml"
pull_request:
paths-ignore:
- "**.md"
- "LICENSE*"
- ".gitignore"
- ".vscode/**"
- "whitelist.txt"
- "docs/**"
- "mkdocs.yml"
workflow_dispatch:
inputs:
depth:
description: "full = fill every feature (hours); collect = validate the config and collect the tests (minutes)"
required: true
type: choice
options: [full, collect]
default: full
features:
description: "Features to rehearse, e.g. monad_runloop. Empty = an EIP branch's own feature, or every feature elsewhere."
required: false
type: string

concurrency:
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
cancel-in-progress: ${{ github.ref_name != github.event.repository.default_branch }}

permissions:
contents: read

jobs:
setup:
runs-on: ubuntu-latest
outputs:
features: ${{ steps.matrix.outputs.features }}
fill_matrix: ${{ steps.matrix.outputs.fill_matrix }}
fill: ${{ steps.depth.outputs.fill }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: false
- uses: ./.github/actions/setup-uv

- name: Select the features to rehearse
id: matrix
shell: bash
env:
INPUT_FEATURES: ${{ inputs.features }}
# The head branch, so a pull request from an EIP branch
# rehearses that branch's feature; `ref_name` is the merge ref
# on a pull request and the branch everywhere else.
BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
# The feature selection and the per-feature release matrix live
# in (and are shared with the release workflow via)
# check_release_matrix.py.
uv run -q .github/scripts/check_release_matrix.py \
"$INPUT_FEATURES" "$BRANCH" | tee -a "$GITHUB_OUTPUT"

- name: Decide whether to fill
id: depth
shell: bash
env:
EVENT: ${{ github.event_name }}
DEPTH: ${{ inputs.depth }}
run: |
# Every automatic trigger fills; only a dispatch can ask for
# the cheap tier alone.
if [ "$EVENT" = "workflow_dispatch" ] && [ "$DEPTH" != "full" ]
then
fill=false
else
fill=true
fi
echo "Fill: $fill"
echo "fill=$fill" >> "$GITHUB_OUTPUT"

collect:
name: collect (${{ matrix.feature }})
needs: setup
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
# A rehearsal wants every feature's verdict, not the first failure.
fail-fast: false
matrix:
feature: ${{ fromJson(needs.setup.outputs.features) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
- uses: ./.github/actions/setup-uv
- name: Install EEST
run: uv sync --no-progress

- name: Extract fixture release properties from config
id: properties
run: |
uv run -q .github/scripts/get_release_props.py ${{ matrix.feature }} >> "$GITHUB_OUTPUT"

- name: Collect the release's tests
shell: bash
run: |
# `fill-params` is interpolated rather than passed through a
# variable so its quoting survives, as in `build-fixtures`.
# Collecting nothing means an empty release, so pytest's exit
# code 5 fails here where a release's fork-range split
# tolerates it.
status=0
uv run fill --collect-only -q \
${{ steps.properties.outputs.fill-params }} > collected.txt \
|| status=$?
tail -n 20 collected.txt
exit "$status"

fill:
name: fill (${{ matrix.label || matrix.feature }})
needs: [setup, collect]
if: needs.setup.outputs.fill == 'true'
runs-on: ubuntu-24.04
# Hosted runners cap a job at six hours regardless; the explicit
# timeout keeps a wedged fill from burning the whole budget.
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.setup.outputs.fill_matrix) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true

- uses: ./.github/actions/build-fixtures
with:
release_name: ${{ matrix.feature }}
from_fork: ${{ matrix.from_fork }}
until_fork: ${{ matrix.until_fork }}
split_label: ${{ matrix.label }}
# A rehearsal only reports whether the fill succeeds; keeping
# its fixtures would cost storage for output nothing consumes.
upload: "false"
Original file line number Diff line number Diff line change
Expand Up @@ -1265,7 +1265,9 @@ def _process_with_marker_args(
"Missing fork argument with 'valid_at_transition_to' marker."
)

if len(forks) > 1:
# A single EIP argument expands to one fork per enabling fork, so
# the limit is on the arguments rather than on the resolved forks.
if len(fork_args) > 1:
raise Exception(
"Too many forks specified to 'valid_at_transition_to' marker."
)
Expand Down
30 changes: 27 additions & 3 deletions packages/testing/src/execution_testing/forks/base_fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,25 @@ def _maybe_transitioned(fork_cls: "BaseForkMeta") -> "BaseForkMeta":
@staticmethod
def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool:
"""
Check if `a` is a subclass of `b`, taking fork transitions into
account.
Check if `a` is a subclass of `b`, taking fork transitions and
declared succession into account.

A fork can follow another fork it does not inherit from, which
places it after that fork (and after everything that fork comes
after) in the fork order without adopting its behavior.
"""
a = BaseForkMeta._maybe_transitioned(a)
b = BaseForkMeta._maybe_transitioned(b)
return issubclass(a, b)
if issubclass(a, b):
return True
# The metaclass sees its instances as plain classes, so the
# trait is reached through a cast, as elsewhere in this class.
followed = cast(Type["BaseFork"], a).follows()
while followed is not None:
if issubclass(followed, b):
return True
followed = followed.follows()
return False

def __gt__(cls, other: "BaseForkMeta") -> bool:
"""Compare if a fork is newer than some other fork (cls > other)."""
Expand Down Expand Up @@ -1407,6 +1420,17 @@ def enabling_forks(cls) -> Set[Type["BaseFork"]]:
raise Exception(f"Class {cls.__name__} is not an EIP.")
return cls._enabling_forks

@classmethod
def follows(cls) -> Type["BaseFork"] | None:
"""
Return the fork this one comes after without inheriting it.

A fork that reuses another lineage's ordering overrides this;
comparisons then place it after that fork, and after everything
that fork comes after, while its behavior stays its own.
"""
return None

@classmethod
def parent(cls) -> Type["BaseFork"] | None:
"""Return the parent fork."""
Expand Down
Loading