Skip to content

Commit

Permalink
Add combine-durations composite action (#126)
Browse files Browse the repository at this point in the history
Originally implemented in conda/conda we port the combine-durations
composite action over to conda/actions so other repositories (like
conda/conda-build) can also benefit from this workflow.

This workflow will load the duration artifacts from the 10 most recent
runs for a given workflow, aggregate and average the durations, commit the
updated duration files, and finally open a PR with the changes.
  • Loading branch information
kenodegard authored May 7, 2024
1 parent 5cb7ed2 commit c1c5971
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ repos:
- id: check-yaml
# catch git merge/rebase problems
- id: check-merge-conflict
# sort requirements files
- id: file-contents-sorter
files: |
(?x)^(
combine-durations/requirements\.txt
)
args: [--unique]
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.13.0
hooks:
Expand Down
27 changes: 27 additions & 0 deletions combine-durations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Combine Durations

A composite GitHub Action to combine the duration files from recent pytest runs.

## GitHub Action Usage

In your GitHub repository include this action in your workflows:

```yaml
- uses: conda/actions/combine-durations
with:
# [optional]
# the git branch to search
branch: main

# [optional]
# the glob pattern to search for duration files
pattern: '*-all'

# [optional]
# the GitHub token with artifact downloading permissions
token: ${{ github.token }}

# [optional]
# the workflow to search
workflow: tests.yml
```
86 changes: 86 additions & 0 deletions combine-durations/action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Combine test durations from all recent runs."""
from __future__ import annotations

import json
from argparse import ArgumentParser, ArgumentTypeError, Namespace
from functools import partial
from pathlib import Path
from statistics import fmean

from rich.console import Console

print = Console(color_system="standard", soft_wrap=True).print


def validate_dir(value: str, writable: bool = False) -> Path:
try:
path = Path(value).expanduser().resolve()
path.mkdir(parents=True, exist_ok=True)
if writable:
ignore = path / ".ignore"
ignore.touch()
ignore.unlink()
return path
except (FileExistsError, PermissionError) as err:
# FileExistsError: value is a file, not a directory
# PermissionError: value is not writable
raise ArgumentTypeError(f"{value} is not a valid directory: {err}")


def parse_args() -> Namespace:
# parse CLI for inputs
parser = ArgumentParser()
parser.add_argument("--durations-dir", type=validate_dir, required=True)
parser.add_argument(
"--artifacts-dir",
type=partial(validate_dir, writable=True),
required=True,
)
return parser.parse_args()


def main() -> None:
args = parse_args()

# aggregate new durations
combined: dict[str, dict[str, list[float]]] = {}
for path in args.artifacts_dir.glob("**/*.json"):
os_combined = combined.setdefault((OS := path.stem), {})

new_data = json.loads(path.read_text())
for key, value in new_data.items():
os_combined.setdefault(key, []).append(value)

# aggregate old durations
for path in args.durations_dir.glob("*.json"):
try:
os_combined = combined[(OS := path.stem)]
except KeyError:
# KeyError: OS not present in new durations
print(f"⚠️ {OS} not present in new durations, removing")
path.unlink()
continue

old_data = json.loads(path.read_text())
if missing := set(old_data) - set(combined[OS]):
for name in missing:
print(f"⚠️ {OS}::{name} not present in new durations, removing")

# only copy over keys that are still present in new durations
for key in set(old_data) & set(combined[OS]):
os_combined[key].append(old_data[key])

# drop durations no longer present in new durations and write out averages
for OS, os_combined in combined.items():
(args.durations_dir / f"{OS}.json").write_text(
json.dumps(
{key: fmean(values) for key, values in os_combined.items()},
indent=4,
sort_keys=True,
)
+ "\n" # include trailing newline
)


if __name__ == "__main__":
main()
66 changes: 66 additions & 0 deletions combine-durations/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Combine Durations
description: Combine the duration files from recent pytest runs.
author: Anaconda Inc.
branding:
icon: book-open
color: green

inputs:
branch:
description: The branch to search for recent pytest runs.
default: main
durations-dir:
description: The directory to write the combined durations file.
default: durations
pattern:
description: The pattern to search for recent pytest runs.
default: '*-all'
token:
description: >-
A token with ability to comment, label, and modify the commit status
(`pull_request: write` and `statuses: write` for fine-grained PAT; `repo` for classic PAT)
default: ${{ github.token }}
workflow:
description: The workflow to search for recent pytest runs.
default: tests.yml

runs:
using: composite
steps:
- name: download recent artifacts
shell: bash
run: >
gh run list
--branch ${{ inputs.branch }}
--workflow ${{ inputs.workflow }}
--limit 10
--json databaseId
--jq '.[].databaseId'
| xargs -n 1
gh run download
--dir ${{ runner.temp }}/artifacts/
--pattern '${{ inputs.pattern }}'
|| true
env:
GITHUB_TOKEN: ${{ github.token }}

- uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: ~/.cache/pip
# invalidate the cache anytime a workflow changes
key: ${{ hashFiles('.github/workflows/*') }}

- uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '3.11'

- name: install dependencies
shell: bash
run: pip install --quiet -r ${{ github.action_path }}/requirements.txt

- name: combine recent durations from artifacts
shell: bash
run: >
python ${{ github.action_path }}/action.py
--durations-dir=${{ inputs.durations-dir }}
--artifacts-dir=${{ runner.temp }}/artifacts/
1 change: 1 addition & 0 deletions combine-durations/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rich

0 comments on commit c1c5971

Please sign in to comment.