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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ jobs:
- name: Install workspace dependencies
run: dart pub get

# Integration target selection treats an unchanged lockfile as unchanged resolution.
- name: Check committed lockfile
run: |
if ! git diff --exit-code pubspec.lock; then
echo "::error file=pubspec.lock::dart pub get changed pubspec.lock. Run dart pub get and commit pubspec.lock."
exit 1
fi

- name: Set PR diff base
if: github.event_name == 'pull_request'
run: |
Expand Down
21 changes: 14 additions & 7 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ on:
pull_request:
push:
branches: [main]
schedule:
# Weekly full regression run; pushes to main only run affected targets.
- cron: '23 19 * * 0'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: integration-${{ github.event.pull_request.number || github.ref }}
# Only PR runs share a group: a replaced pending run on main would leave its pushed changes untested.
group: integration-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
Expand All @@ -30,13 +34,16 @@ jobs:
id: targets
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
args=()
if [[ "$EVENT" == "pull_request" ]]; then
args+=(--base "$BASE" --head "$HEAD")
fi
case "$EVENT" in
pull_request) args=(--base "$PR_BASE" --head "$PR_HEAD") ;;
push) args=(--base "$PUSH_BEFORE" --head "$GITHUB_SHA") ;;
# schedule and workflow_dispatch run every target.
*) args=() ;;
esac
python3 -B tool/ci/select_integration_targets.py "${args[@]}" --output "$GITHUB_OUTPUT"

integration:
Expand Down
100 changes: 88 additions & 12 deletions tool/ci/select_integration_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pathlib import Path
import re
import subprocess
import sys


ROOT = Path(__file__).resolve().parents[2]
Expand Down Expand Up @@ -61,6 +62,16 @@ def load_targets(root):
return targets


def diffable_base(root, base):
"""Return base when it names a local commit, or None when a push diff cannot be computed.

A new branch reports an all-zero base, and a force push can report a base
that is no longer in the history.
"""
exists = subprocess.run(['git', 'cat-file', '-e', f'{base}^{{commit}}'], cwd=root, capture_output=True)
return base if exists.returncode == 0 else None


def changed_files(root, base, head):
# Include both sides of moves so moving a file out of a package still tests it.
output = subprocess.check_output(
Expand All @@ -70,29 +81,94 @@ def changed_files(root, base, head):
return output.decode().split('\0')[:-1]


def select_targets(targets, paths):
def read_pubspec(text):
"""Read the package name, workspace members, and direct dependency names from pubspec text.

Only the top-level keys this selector needs are parsed, line by line, because
the standard library has no YAML parser.
"""
pubspec = {'name': None, 'workspace': [], 'dependencies': set()}
section = child_indent = None
for line in text.splitlines():
content = line.split('#', 1)[0].rstrip()
if not content.strip():
continue
indent = len(content) - len(content.lstrip())
if indent == 0:
section, _, value = content.partition(':')
child_indent = None
if section == 'name':
pubspec['name'] = value.strip().strip('\'"')
continue
if child_indent is None:
child_indent = indent
if section == 'workspace' and content.lstrip().startswith('- '):
pubspec['workspace'].append(content.lstrip()[2:].strip().strip('\'"').rstrip('/'))
elif section in {'dependencies', 'dev_dependencies'} and indent == child_indent:
match = re.fullmatch(r'\s*([A-Za-z_][A-Za-z0-9_]*)\s*:.*', content)
if match:
pubspec['dependencies'].add(match.group(1))
return pubspec


def load_workspace(root):
"""Map each workspace member directory to its parsed pubspec."""
members = read_pubspec((root / 'pubspec.yaml').read_text())['workspace']
return {member: read_pubspec((root / member / 'pubspec.yaml').read_text()) for member in members}


def base_manifest_targets(root, base, head):
"""Return the registered targets at the PR merge base, or none when the manifest did not exist."""
merge_base = subprocess.check_output(['git', 'merge-base', base, head], cwd=root).decode().strip()
revision = f'{merge_base}:.github/integration-tests.json'
if subprocess.run(['git', 'cat-file', '-e', revision], cwd=root, capture_output=True).returncode:
return []
return json.loads(subprocess.check_output(['git', 'show', revision], cwd=root))['targets']


def select_targets(targets, paths, base_targets=(), workspace=None):
if paths is None:
return targets
shared = any(
path in {'pubspec.yaml', 'pubspec.lock', '.github/integration-tests.json'}
or path.startswith(('.github/workflows/', 'tool/ci/'))
or (path.startswith('packages/') and path.endswith('/pubspec.yaml'))
# Lockfile and CI changes can affect every job.
if any(path == 'pubspec.lock' or path.startswith(('.github/workflows/', 'tool/ci/')) for path in paths):
return targets
workspace = workspace or {}
packages = {path.split('/')[1] for path in paths if path.startswith('packages/') and path.count('/') > 1}
# A member pubspec change also affects workspace members that depend on it, directly or transitively.
names = {
workspace[path.removesuffix('/pubspec.yaml')]['name']
for path in paths
)
return [target for target in targets if shared or any(
path.startswith(f'packages/{target["package"]}/') for path in paths
if path.endswith('/pubspec.yaml') and path.removesuffix('/pubspec.yaml') in workspace
}
while dependents := {
pubspec['name'] for pubspec in workspace.values() if pubspec['dependencies'] & names
} - names:
names |= dependents
packages |= {
member.split('/')[1]
for member, pubspec in workspace.items()
if member.startswith('packages/') and pubspec['name'] in names
}
# A manifest change affects only added or edited targets; removed targets need no job.
manifest_changed = '.github/integration-tests.json' in paths
return [target for target in targets if target['package'] in packages or (
manifest_changed and target not in base_targets
)]


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', help='PR base SHA; omit to select all targets')
parser.add_argument('--head', default='HEAD', help='PR head SHA')
parser.add_argument('--base', help='PR base or pushed-before SHA; omit to select all targets')
parser.add_argument('--head', default='HEAD', help='PR head or pushed SHA')
parser.add_argument('--output', type=Path, help='GitHub Actions output file')
args = parser.parse_args()
targets = load_targets(ROOT)
paths = changed_files(ROOT, args.base, args.head) if args.base else None
selected = select_targets(targets, paths)
base = args.base and diffable_base(ROOT, args.base)
if args.base and not base:
print(f'Cannot diff from {args.base}; selecting all targets.', file=sys.stderr)
paths = changed_files(ROOT, base, args.head) if base else None
base_targets = base_manifest_targets(ROOT, base, args.head) if base else []
selected = select_targets(targets, paths, base_targets, load_workspace(ROOT))
matrix = json.dumps({'include': selected}, separators=(',', ':'))
print(matrix)
if args.output:
Expand Down
Loading
Loading