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
54 changes: 50 additions & 4 deletions .github/workflows/ci-timing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ name: CI Timing
# The periodic CI-duration review (#1538). util/ci_timing.py reads the last
# week of runs through the API, writes a report to the job summary and an
# artifact, and fails when a HARD threshold fires (status 1; 2 and 3 are an
# unreadable API and a crash). GitHub emails a scheduled workflow's failures to
# whoever last edited its cron line, so no `issues: write` is needed. The
# thresholds are in docs/source/contributing/workflows.rst, the response to one
# in conventions/process.rst.
# unreadable API and a crash), which GitHub emails to whoever last edited the
# cron line. The `discussion` job then posts the report to this job's thread in
# the Discussions category REPORT_CATEGORY names (#2030); it alone holds a write
# scope, and a report it cannot post is a warning. The thresholds are in
# docs/source/contributing/workflows.rst, the response to one in
# conventions/process.rst.
#
# Not a `workflow_run` on Unit Tests: that adds a job per run to a runner pool
# already measured saturated.
Expand Down Expand Up @@ -36,6 +38,11 @@ concurrency:
group: ci-timing
cancel-in-progress: false

# #2030: one Discussions category holds every report with no pull request to
# comment on, one thread per job.
env:
REPORT_CATEGORY: CI reports

jobs:
report:
name: CI timing report
Expand Down Expand Up @@ -117,3 +124,42 @@ jobs:
report.json
report.md
retention-days: 90

# #2030: posts the report the `report` job uploaded, on `always()` like that
# job's own upload, so a run failed by a HARD threshold or stopped by a cap
# is posted too. util/discussion_report.py writes a warning rather than
# failing; `continue-on-error` covers the rest.
discussion:
name: Post the CI timing report
needs: [ report ]
if: ${{ always() && needs.report.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
permissions:
contents: read
discussions: write

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# A run that never wrote a report uploaded none; the script then warns.
- name: Download the report
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ci-timing-report
path: report

- name: Post the report to Discussions
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
STATUS: ${{ needs.report.result }}
# `scheduled` on dispatch too: a manual run is a deliberate report.
run: |
python3 util/discussion_report.py --category "$REPORT_CATEGORY" \
--title 'ci-timing: weekly CI timing report' --report report/report.md \
--mode scheduled --status "$STATUS"
131 changes: 131 additions & 0 deletions .github/workflows/cron-vendor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ concurrency:
group: vendor-update-${{ github.event.workflow_run.head_sha || github.ref }}
cancel-in-progress: false

# #2030: one Discussions category holds every report with no pull request to
# comment on, one thread per job.
env:
REPORT_CATEGORY: CI reports

jobs:
# #1967: the test gate on the schedule, `verdict` on a `workflow_run`;
# shared with Conda Update and GitHub Pages.
Expand Down Expand Up @@ -61,6 +66,9 @@ jobs:
github.event.workflow_run.head_branch == 'main' &&
needs.head.outputs.verified == 'true'
))
# #2030: `warning` where the job passed over a failed target or test gate.
outputs:
status: ${{ steps.run-report.outputs.status }}
concurrency:
group: repository-maintenance
cancel-in-progress: false
Expand Down Expand Up @@ -106,6 +114,7 @@ jobs:
python -m pip install -e '.[all,vendor]'

- name: Update Vendor
id: crawl
run: |
set -x

Expand All @@ -119,6 +128,7 @@ jobs:
pcapkit-vendor 2> "$RUNNER_TEMP/vendor-stderr.log"
vendor_exit=$?
set -e
echo "status=$vendor_exit" >> "$GITHUB_OUTPUT"

# Replayed whatever the exit: each failing target's name and
# exception, plus its VendorRuntimeWarning. `|| true` so a log that
Expand Down Expand Up @@ -306,3 +316,124 @@ jobs:
git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git
git pull --rebase origin "${GITHUB_REF#refs/heads/}"
git push origin "HEAD:${GITHUB_REF}"

# #2030: the run's outcome for the `discussion` job to post, read from the
# crawl's log and the steps above, and `status=warning` where a target or
# the test gate failed without failing the job. `continue-on-error` on
# both, since a failure here would fail the run Create Release reads.
- name: Write the run report
id: run-report
if: ${{ !cancelled() }}
continue-on-error: true
env:
CRAWL_STATUS: ${{ steps.crawl.outputs.status }}
TESTS: ${{ steps.vendor-tests.outcome }}
FILES_CHANGED: ${{ steps.verify-changed-files.outputs.files_changed }}
COMMITTED: ${{ steps.commit.outcome == 'success' || steps.commit-docs.outputs.committed == 'true' }}
run: |
if [ "$CRAWL_STATUS" = 0 ] && [ "$TESTS" = success ]; then
echo "status=success" >> "$GITHUB_OUTPUT"
else
echo "status=warning" >> "$GITHUB_OUTPUT"
fi
{
echo '### Vendor Update'
echo
if [ -z "$CRAWL_STATUS" ]; then
echo '- Crawl: did not finish'
elif [ "$CRAWL_STATUS" = 0 ]; then
echo '- Crawl: every target succeeded'
else
echo "- Crawl: pcapkit-vendor exited $CRAWL_STATUS; these targets failed and kept their modules:"
echo
echo '```'
grep -E '^[A-Za-z_][A-Za-z0-9_.]* failed: ' "$RUNNER_TEMP/vendor-stderr.log" ||
echo '(no target name could be read from stderr)'
echo '```'
echo
fi
echo "- Test gate: ${TESTS:-not run}"
if [ "$COMMITTED" = true ]; then
echo "- Committed: $(git log -1 --format=%s)"
elif [ "$FILES_CHANGED" = true ]; then
echo '- Committed: nothing, though the crawl changed files'
elif [ "$FILES_CHANGED" = false ]; then
echo '- Committed: nothing, as nothing changed'
else
echo '- Committed: nothing, as the job stopped first'
fi
} > "$RUNNER_TEMP/vendor-report.md"
cat "$RUNNER_TEMP/vendor-report.md"

- name: Upload the run report
if: ${{ !cancelled() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vendor-report
path: ${{ runner.temp }}/vendor-report.md
retention-days: 14

# #2030: posts `vendor-update`'s report, and a short one of its own where
# `head` failed or was cancelled, since a failed gate skips `vendor-update`.
# `always()`, so that skip does not skip this job, but no deliberate skip
# runs it: where `vendor-update` skipped because the verdict did not cover
# the commit or the run is a pull request's, so does this, and a run whose
# every job skipped still concludes `skipped` for Create Release. Job-level
# `continue-on-error` as well as the steps', so nothing here can fail the run
# Create Release reads; util/discussion_report.py writes a warning instead.
discussion:
name: Post the vendor report
needs: [ head, vendor-update ]
if: >-
${{ always() && (needs.vendor-update.result != 'skipped' ||
needs.head.result == 'failure' || needs.head.result == 'cancelled') }}
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
permissions:
contents: read
discussions: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# A run that never wrote a report uploaded none; the script then warns.
- name: Download the report
if: needs.vendor-update.result != 'skipped'
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: vendor-report
path: report

# A `workflow_run` follows a push to main, so it posts as a push run: only
# when it warned or failed, and once more on the next green run. The
# schedule posts every run. A green job that warned reports `warning`, and
# a run whose gate stopped `vendor-update` reports the gate's result.
- name: Post the report to Discussions
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
MODE: ${{ github.event_name == 'workflow_run' && 'push' || 'scheduled' }}
HEAD_RESULT: ${{ needs.head.result }}
VENDOR_RESULT: ${{ needs.vendor-update.result }}
VENDOR_STATUS: ${{ needs.vendor-update.outputs.status }}
run: |
if [ "$VENDOR_RESULT" = skipped ]; then
status="$HEAD_RESULT"
mkdir -p report
{
echo '### Vendor Update'
echo
echo "- Gate: $HEAD_RESULT, so vendor-update did not run and nothing was crawled or committed"
} > report/vendor-report.md
elif [ "$VENDOR_RESULT" = success ] && [ -n "$VENDOR_STATUS" ]; then
status="$VENDOR_STATUS"
else
status="$VENDOR_RESULT"
fi
python3 util/discussion_report.py --category "$REPORT_CATEGORY" \
--title 'cron-vendor: vendor constant update report' --report report/vendor-report.md \
--mode "$MODE" --status "$status"
47 changes: 46 additions & 1 deletion docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,9 @@ cancelled at 95% of its cap or later timed out and counts, even in a run that re
``cancelled`` for that reason; one cancelled earlier was superseded and does not. A
step cap written as an expression differs by leg and reads as none.
The report goes to the job summary and to the ``ci-timing-report`` artifact (90
days), and the newest earlier report is the next run's baseline. It is not a
days), and the newest earlier report is the next run's baseline. The
``discussion`` job then posts it to the ``ci-timing: weekly CI timing report``
thread (see `CI Reports`_). It is not a
``workflow_run`` on Unit Tests, which would add a job per run to a runner pool
measured saturated at 20 concurrent jobs.

Expand Down Expand Up @@ -1213,6 +1215,9 @@ does; each has tests under :file:`tests/project/` that run it.
``experimental`` and ``python-compatibility.yml``'s ``compatibility``, at the
job's ``ANNOTATION_LEVEL``, and of ``unittest-ordering``, ``project-tests`` and
``gate``, at ``error``.
* - :file:`util/discussion_report.py`
- ``Post the report to Discussions`` in the ``discussion`` job of
``ci-timing.yml`` and of ``cron-vendor.yml`` (see `CI Reports`_).

The summaries run on the image's ``/usr/bin/python3`` rather than the leg's
interpreter, which may have failed to set up or be too broken to run anything, so
Expand All @@ -1229,6 +1234,46 @@ tree. ``create-release.yml``'s ``pypi`` job keeps its own copy of
the KTLO summary's shell until its shell moves to scripts too; a project pin runs
that copy and the script on the same steps and requires the same output.

.. _CI Reports:

CI Reports (`#2030 <https://github.com/JarryShaw/PyPCAPKit/issues/2030>`__)
---------------------------------------------------------------------------

A report with no pull request to comment on goes to GitHub Discussions. One
category, ``CI reports``, holds every report, and each job has one thread in it,
titled ``<workflow file stem>: <job or report name>``. A run adds a comment to its
job's thread rather than opening a new one; the comment links the run, names its
commit and its status, and ends in a hidden ``<!-- report-status: ... -->``
marker. How often depends on the script's ``--mode``:

* ``scheduled``: every run posts, on ``schedule`` and on ``workflow_dispatch``,
since a manual run is a deliberate report.
* ``push``, for a run on a push or a ``workflow_run`` relay of one: a run that
warned or failed posts every time, and a green run posts only where the
thread's newest marker is not ``success``, so one "back to green" comment
follows a bad run and later green runs stay silent. A thread with no marker
counts as green.

``ci-timing.yml`` posts as ``scheduled`` to ``ci-timing: weekly CI timing
report``. ``cron-vendor.yml`` posts to ``cron-vendor: vendor constant update
report``, as ``push`` on its ``workflow_run`` and ``scheduled`` otherwise, and
reports ``warning`` where ``vendor-update`` passed over a failed target or test
gate. A failed or cancelled ``head`` skips ``vendor-update``, so its
``discussion`` job runs on ``always()`` and then posts a line of its own with
``head``'s result; a skip on purpose, on a pull request's ``workflow_run`` or a
verdict for another commit, skips it too. The category is the workflow-level ``REPORT_CATEGORY``. The API cannot
create one, so it is made by hand, in Settings, Discussions.

The reporting job uploads its report as an artifact, and a ``discussion`` job of
its own downloads it and runs :file:`util/discussion_report.py`, which finds the
category and the thread by name and opens the thread on first use. That job alone
holds ``discussions: write``, beside ``contents: read`` for its checkout, so every
other job keeps its permissions. It uses ``GITHUB_TOKEN`` and first-party actions
only. Reporting never fails a run: a missing category, a missing report or a
failed API call is one warning, and the job is ``continue-on-error``, since a
failed Vendor Update run is one Create Release reads as not updated. A CI timing
run failed by a HARD threshold is posted all the same, on ``always()``.

.. _Action Pins:

Action Pins (`#1968 <https://github.com/JarryShaw/PyPCAPKit/issues/1968>`__)
Expand Down
8 changes: 5 additions & 3 deletions tests/project/test_ci_timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,7 +1163,7 @@ def test_the_reusable_workflow_asks_for_no_write_permission(self) -> 'None':


class TestReviewWorkflow(unittest.TestCase):
""":file:`ci-timing.yml` stays read-only, weekly, and off Unit Tests' runs."""
""":file:`ci-timing.yml` stays read-only but for posting its report (#2030), weekly, and off Unit Tests' runs."""

def setUp(self) -> 'None':
self.text = REVIEW.read_text(encoding='utf-8')
Expand All @@ -1177,13 +1177,15 @@ def test_triggers(self) -> 'None':
self.assertNotRegex(self.code, rf'(?m)^ {trigger}:')

def test_permissions_are_read_only(self) -> 'None':
"""But for the ``discussion`` job's ``discussions: write``, pinned in test_discussion_report_2030."""
block = re.search(r'(?ms)^permissions:\n(.*?)(?=^\S)', self.code).group(1)
self.assertEqual(sorted(block.split()), ['actions:', 'contents:', 'read', 'read'])
self.assertNotIn('write', self.code)
self.assertEqual(re.findall(r'(?m)^\s+([\w-]+):\s*write\s*$', self.code), ['discussions'])

def test_the_job(self) -> 'None':
specs = ci_timing.parse_workflow(self.text)
self.assertEqual([(spec['name'], spec['timeout']) for spec in specs], [('CI timing report', 40)])
self.assertEqual([(spec['name'], spec['timeout']) for spec in specs],
[('CI timing report', 40), ('Post the CI timing report', 10)])
self.assertIn('python util/ci_timing.py', self.code)
# The upload and the baseline download name the same artifact.
self.assertIn('name: ci-timing-report', self.code)
Expand Down
Loading
Loading