Skip to content

Commit aac9a15

Browse files
authored
CI improvements (#342)
1 parent 8c781ee commit aac9a15

File tree

3 files changed

+287
-23
lines changed

3 files changed

+287
-23
lines changed

.github/workflows/ci.yaml

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
python-version: ["3.8", "3.9", "3.10"]
2525
steps:
2626
- uses: actions/checkout@v3
27+
with:
28+
# need to fetch all tags to get a correct version
29+
fetch-depth: 0 # fetch all branches and tags
2730
- uses: conda-incubator/setup-miniconda@v2
2831
with:
2932
channels: conda-forge
@@ -57,29 +60,9 @@ jobs:
5760
runs-on: ubuntu-latest
5861
steps:
5962
- uses: actions/checkout@v3
60-
- uses: conda-incubator/setup-miniconda@v2
6163
with:
62-
channels: conda-forge
63-
mamba-version: "*"
64-
activate-environment: cf_xarray_test
65-
auto-update-conda: false
66-
python-version: ${{ matrix.python-version }}
67-
- name: Set up conda environment
68-
shell: bash -l {0}
69-
run: |
70-
mamba env update -f ci/environment-no-optional-deps.yml
71-
python -m pip install -e .
72-
conda list
73-
- name: Run Tests
74-
shell: bash -l {0}
75-
run: |
76-
pytest -n 2
77-
78-
upstream-dev:
79-
name: upstream-dev
80-
runs-on: ubuntu-latest
81-
steps:
82-
- uses: actions/checkout@v3
64+
# need to fetch all tags to get a correct version
65+
fetch-depth: 0 # fetch all branches and tags
8366
- uses: conda-incubator/setup-miniconda@v2
8467
with:
8568
channels: conda-forge
@@ -90,7 +73,7 @@ jobs:
9073
- name: Set up conda environment
9174
shell: bash -l {0}
9275
run: |
93-
mamba env update -f ci/upstream-dev-env.yml
76+
mamba env update -f ci/environment-no-optional-deps.yml
9477
python -m pip install -e .
9578
conda list
9679
- name: Run Tests

.github/workflows/parse_logs.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# type: ignore
2+
import argparse
3+
import functools
4+
import json
5+
import pathlib
6+
import textwrap
7+
from dataclasses import dataclass
8+
9+
from pytest import CollectReport, TestReport
10+
11+
12+
@dataclass
13+
class SessionStart:
14+
pytest_version: str
15+
outcome: str = "status"
16+
17+
@classmethod
18+
def _from_json(cls, json):
19+
json_ = json.copy()
20+
json_.pop("$report_type")
21+
return cls(**json_)
22+
23+
24+
@dataclass
25+
class SessionFinish:
26+
exitstatus: str
27+
outcome: str = "status"
28+
29+
@classmethod
30+
def _from_json(cls, json):
31+
json_ = json.copy()
32+
json_.pop("$report_type")
33+
return cls(**json_)
34+
35+
36+
def parse_record(record):
37+
report_types = {
38+
"TestReport": TestReport,
39+
"CollectReport": CollectReport,
40+
"SessionStart": SessionStart,
41+
"SessionFinish": SessionFinish,
42+
}
43+
cls = report_types.get(record["$report_type"])
44+
if cls is None:
45+
raise ValueError(f"unknown report type: {record['$report_type']}")
46+
47+
return cls._from_json(record)
48+
49+
50+
@functools.singledispatch
51+
def format_summary(report):
52+
return f"{report.nodeid}: {report}"
53+
54+
55+
@format_summary.register
56+
def _(report: TestReport):
57+
message = report.longrepr.chain[0][1].message
58+
return f"{report.nodeid}: {message}"
59+
60+
61+
@format_summary.register
62+
def _(report: CollectReport):
63+
message = report.longrepr.split("\n")[-1].removeprefix("E").lstrip()
64+
return f"{report.nodeid}: {message}"
65+
66+
67+
def format_report(reports, py_version):
68+
newline = "\n"
69+
summaries = newline.join(format_summary(r) for r in reports)
70+
message = textwrap.dedent(
71+
"""\
72+
<details><summary>Python {py_version} Test Summary</summary>
73+
74+
```
75+
{summaries}
76+
```
77+
78+
</details>
79+
"""
80+
).format(summaries=summaries, py_version=py_version)
81+
return message
82+
83+
84+
if __name__ == "__main__":
85+
parser = argparse.ArgumentParser()
86+
parser.add_argument("filepath", type=pathlib.Path)
87+
args = parser.parse_args()
88+
89+
py_version = args.filepath.stem.split("-")[1]
90+
91+
print("Parsing logs ...")
92+
93+
lines = args.filepath.read_text().splitlines()
94+
reports = [parse_record(json.loads(line)) for line in lines]
95+
96+
failed = [report for report in reports if report.outcome == "failed"]
97+
98+
message = format_report(failed, py_version=py_version)
99+
100+
output_file = pathlib.Path("pytest-logs.txt")
101+
print(f"Writing output file to: {output_file.absolute()}")
102+
output_file.write_text(message)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
name: CI Upstream
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request:
7+
branches:
8+
- main
9+
schedule:
10+
- cron: "0 0 * * *" # Daily “At 00:00” UTC
11+
workflow_dispatch: # allows you to trigger the workflow run manually
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
detect-ci-trigger:
19+
name: detect upstream-dev ci trigger
20+
runs-on: ubuntu-latest
21+
if: |
22+
github.repository == 'xarray-contrib/cf-xarray'
23+
&& (github.event_name == 'push' || github.event_name == 'pull_request')
24+
outputs:
25+
triggered: ${{ steps.detect-trigger.outputs.trigger-found }}
26+
steps:
27+
- uses: actions/checkout@v3
28+
with:
29+
fetch-depth: 2
30+
- uses: xarray-contrib/[email protected]
31+
id: detect-trigger
32+
with:
33+
keyword: "[test-upstream]"
34+
35+
upstream-dev:
36+
name: upstream-dev
37+
runs-on: ubuntu-latest
38+
needs: detect-ci-trigger
39+
if: |
40+
always()
41+
&& (
42+
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
43+
|| needs.detect-ci-trigger.outputs.triggered == 'true'
44+
)
45+
defaults:
46+
run:
47+
shell: bash -l {0}
48+
strategy:
49+
fail-fast: false
50+
matrix:
51+
python-version: ["3.10"]
52+
outputs:
53+
artifacts_availability: ${{ steps.status.outputs.ARTIFACTS_AVAILABLE }}
54+
steps:
55+
- uses: actions/checkout@v3
56+
with:
57+
fetch-depth: 0 # Fetch all history for all branches and tags.
58+
- name: Set up conda environment
59+
uses: mamba-org/provision-with-micromamba@34071ca7df4983ccd272ed0d3625818b27b70dcc
60+
with:
61+
environment-file: ci/upstream-dev-env.yml
62+
environment-name: cf_xarray_test
63+
extra-specs: |
64+
python=${{ matrix.python-version }}
65+
pytest-reportlog
66+
- name: Install cf-xarray
67+
run: |
68+
python -m pip install --no-deps -e .
69+
- name: Version info
70+
run: |
71+
conda info -a
72+
conda list
73+
- name: import cf_xarray
74+
run: |
75+
python -c 'import cf_xarray'
76+
- name: Run Tests
77+
if: success()
78+
id: status
79+
run: |
80+
python -m pytest -n 2 -rf \
81+
--report-log output-${{ matrix.python-version }}-log.jsonl \
82+
|| (
83+
echo '::set-output name=ARTIFACTS_AVAILABLE::true' && false
84+
)
85+
- name: Upload artifacts
86+
if: |
87+
failure()
88+
&& steps.status.outcome == 'failure'
89+
&& github.event_name == 'schedule'
90+
&& github.repository == 'xarray-contrib/cf-xarray'
91+
uses: actions/upload-artifact@v3
92+
with:
93+
name: output-${{ matrix.python-version }}-log.jsonl
94+
path: output-${{ matrix.python-version }}-log.jsonl
95+
retention-days: 5
96+
97+
report:
98+
name: report
99+
needs: upstream-dev
100+
if: |
101+
failure()
102+
&& github.event_name == 'schedule'
103+
&& needs.upstream-dev.outputs.artifacts_availability == 'true'
104+
runs-on: ubuntu-latest
105+
defaults:
106+
run:
107+
shell: bash
108+
steps:
109+
- uses: actions/checkout@v3
110+
- uses: actions/setup-python@v4
111+
with:
112+
python-version: "3.x"
113+
- uses: actions/download-artifact@v3
114+
with:
115+
path: /tmp/workspace/logs
116+
- name: Move all log files into a single directory
117+
run: |
118+
rsync -a /tmp/workspace/logs/output-*/ ./logs
119+
ls -R ./logs
120+
- name: install dependencies
121+
run: |
122+
python -m pip install pytest
123+
- name: Parse logs
124+
run: |
125+
shopt -s globstar
126+
python .github/workflows/parse_logs.py logs/**/*-log*
127+
cat pytest-logs.txt
128+
- name: Report failures
129+
uses: actions/github-script@v6
130+
with:
131+
github-token: ${{ secrets.GITHUB_TOKEN }}
132+
script: |
133+
const fs = require('fs');
134+
const pytest_logs = fs.readFileSync('pytest-logs.txt', 'utf8');
135+
const title = "⚠️ Nightly upstream-dev CI failed ⚠️"
136+
const workflow_url = `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
137+
const issue_body = `[Workflow Run URL](${workflow_url})\n${pytest_logs}`
138+
139+
// Run GraphQL query against GitHub API to find the most recent open issue used for reporting failures
140+
const query = `query($owner:String!, $name:String!, $creator:String!, $label:String!){
141+
repository(owner: $owner, name: $name) {
142+
issues(first: 1, states: OPEN, filterBy: {createdBy: $creator, labels: [$label]}, orderBy: {field: CREATED_AT, direction: DESC}) {
143+
edges {
144+
node {
145+
body
146+
id
147+
number
148+
}
149+
}
150+
}
151+
}
152+
}`;
153+
154+
const variables = {
155+
owner: context.repo.owner,
156+
name: context.repo.repo,
157+
label: 'CI',
158+
creator: "github-actions[bot]"
159+
}
160+
const result = await github.graphql(query, variables)
161+
162+
// If no issue is open, create a new issue,
163+
// else update the body of the existing issue.
164+
if (result.repository.issues.edges.length === 0) {
165+
github.rest.issues.create({
166+
owner: variables.owner,
167+
repo: variables.name,
168+
body: issue_body,
169+
title: title,
170+
labels: [variables.label]
171+
})
172+
} else {
173+
github.rest.issues.update({
174+
owner: variables.owner,
175+
repo: variables.name,
176+
issue_number: result.repository.issues.edges[0].node.number,
177+
body: issue_body
178+
})
179+
}

0 commit comments

Comments
 (0)