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
91 changes: 91 additions & 0 deletions .github/workflows/milestone.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Milestone

# #2218: sets the release milestone on each issue and pull request as it opens,
# reopens and closes, so every item records the release line it targets and
# ships in without anyone setting it by hand; a manual run backfills the items
# that have none. The rule is util/milestone.py's action_for, documented under
# "Milestone" in docs/source/contributing/workflows.rst: an open item, a merged
# pull request or an issue closed as completed gets the open milestone named
# after the checked-out `pcapkit.__version__`, unless it already holds another;
# an unmerged pull request or an issue closed as not planned loses whichever
# milestone it holds. A missing milestone is a warning, never created.
#
# SECURITY. `pull_request_target` gives this run a write token on events a
# stranger can cause, so it must never execute anything the pull request
# supplies. The job below:
#
# * checks out `github.sha`, which under `pull_request_target` is the tip of
# the *base* branch, never the PR head, and only the three files it reads,
# without persisting the token;
# * reads `__version__` from pcapkit/__init__.py as text, importing nothing;
# * takes nothing from the event payload but the item number, passed through
# `env:`, and re-reads the item's state from the API.
#
# tests/project/test_milestone_2218.py asserts each of these over this file.

on:
issues:
types: [opened, reopened, closed]
pull_request_target:
types: [opened, reopened, closed]
workflow_dispatch:
inputs:
dry_run:
description: Report what the backfill would change, and change nothing
type: boolean
default: true

# The job holds its own scopes.
permissions: {}

# Runs on one item serialize, so an open, close and reopen in quick succession
# never overlap. Nothing is cancelled: by default a group holds one pending run
# and cancels it when a newer one arrives, and `queue: max` lets up to 100 wait
# instead. Queue order is not guaranteed, but each run re-reads the item, so
# the last to execute writes the item's state as it then stands. A backfill
# has a group of its own. GitHub documents `queue`, and that it and
# `cancel-in-progress` together are a validation error:
# https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency
concurrency:
group: milestone-${{ (github.event.issue.number || github.event.pull_request.number) && format('item-{0}', github.event.issue.number || github.event.pull_request.number) || 'backfill' }}
queue: max

jobs:
milestone:
name: Set the milestone
runs-on: ubuntu-latest
# One item is a few API calls. A backfill is one listing page per 100 items
# plus one update per item that changed, a second apart, and a rate-limited
# request may wait minutes before its retry.
timeout-minutes: 60
permissions:
contents: read
issues: write
pull-requests: write

steps:
# The base branch, and only the files the next step reads.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
sparse-checkout: |
pcapkit/__init__.py
util/_actions.py
util/milestone.py
sparse-checkout-cone-mode: false
persist-credentials: false

# A dispatch is a dry run unless `dry_run` is unticked.
- name: Set the milestone
env:
GH_TOKEN: ${{ github.token }}
ITEM: ${{ github.event.issue.number || github.event.pull_request.number }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ -n "$ITEM" ]; then
python3 util/milestone.py --item "$ITEM"
elif [ "$DRY_RUN" = 'false' ]; then
python3 util/milestone.py --backfill
else
python3 util/milestone.py --backfill --dry-run
fi
12 changes: 8 additions & 4 deletions docs/source/contributing/conventions/process.rst
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,15 @@ carrying it are that kind:
Milestones and the Project Board
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Milestones are release lines**, titled by minor version (``1.4``, ``1.5``). An issue
or pull request belongs to the line it ships in: the open milestone collects work
targeting the next release, and it is closed when that release's final tag is cut,
**Milestones are release lines**, titled by minor version (``PyPCAPKit v1.4``,
``PyPCAPKit v1.5``). An issue or pull request belongs to the line it ships in: the
open milestone collects work targeting the next release, and it is closed when that release's final tag is cut,
with ``due_on`` set to the tag date. Work closed without shipping -- an unmerged pull
request, an issue closed as not planned -- takes no milestone. The closed milestones
request, an issue closed as not planned -- takes no milestone.
:file:`.github/workflows/milestone.yml` gives an item the open milestone as it opens,
and settles it as the item closes; a milestone already set by hand to another
release is kept, and a manual run backfills the items without one
(:doc:`../workflows`). The closed milestones
were backfilled from merge and close dates against the release tags, so an item that
landed just before a tag may sit one line early.

Expand Down
74 changes: 70 additions & 4 deletions docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ At a Glance
- daily 00:37
- --
- ``issues``, ``pull_request_target``, ``workflow_dispatch``
* - Milestone
- :file:`milestone.yml`
- --
- --
- --
- --
- ``issues``, ``pull_request_target``, ``workflow_dispatch``
* - Coverage Comment
- :file:`coverage-comment.yml`
- --
Expand Down Expand Up @@ -183,6 +190,7 @@ than starting a new one.
CT["CI Timing<br/>ci-timing.yml"]
BM["Engine Benchmark<br/>benchmark.yml"]
PS["Project Status<br/>project-status.yml"]
MS["Milestone<br/>milestone.yml"]
CR["Create Release<br/>create-release.yml"]

PUSH --> UT
Expand Down Expand Up @@ -227,6 +235,10 @@ than starting a new one.
ISSUES --> PS
PRT --> PS

DISPATCH --> MS
ISSUES --> MS
PRT --> MS

TAG --> CR

classDef trig fill:none,stroke-dasharray:2 2
Expand Down Expand Up @@ -553,8 +565,11 @@ release waits for the next Vendor Update on ``main``.
``pull_request_target``
-----------------------

**Project Status** is the only workflow on ``pull_request_target``, and the only one
that runs with a secret on an event a fork can cause: it writes the project board's
**Project Status** and **Milestone** are the workflows on ``pull_request_target``,
which runs the base branch's copy of a workflow with a write token on an event a fork
can cause, so neither runs anything the pull request supplies.

**Project Status** is the one that runs with a secret: it writes the project board's
*Status* field with ``PROJECT_TOKEN``, which a fork's ``pull_request`` run would not
receive. It therefore never checks out the repository and has no ``uses:`` step. It
fetches :file:`util/project_status.py`, and :file:`util/_actions.py`, which it imports
Expand All @@ -565,6 +580,57 @@ number from the payload.
secret it skips with a ``::notice::``. The Status mapping it applies is documented in
:doc:`conventions/process`.

**Milestone** writes with ``GITHUB_TOKEN``, scoped to ``issues: write`` and
``pull-requests: write`` on its one job, beside ``contents: read`` for its
checkout. Its checkout is of ``github.sha`` without
persisted credentials, and holds :file:`pcapkit/__init__.py`, :file:`util/milestone.py`
and :file:`util/_actions.py` alone; the version is read from the first as text, so
no code from :mod:`pcapkit` is imported. It takes only the item number from the
payload. ``tests/project/test_milestone_2218.py`` asserts this over the file.

Milestone (:issue:`2218`)
-------------------------------------------------------------------------------

Each issue and pull request gets its release milestone as it opens, reopens and
closes, by the rule under "Milestones and the Project Board" in
:doc:`conventions/process`, which ``action_for`` in :file:`util/milestone.py` applies:

* an open item, a merged pull request, or an issue closed as completed gets the open
milestone titled ``PyPCAPKit v<major>.<minor>`` from the checked-out
``__version__`` -- ``1.5.0b14`` gives ``PyPCAPKit v1.5``;
* a pull request closed unmerged, or an issue closed as not planned or as a
duplicate, loses any milestone it has;
* an issue closed with no reason recorded is left as it is.

**A milestone already on an item wins over the one the rule gives.** An item that
holds a different milestone, such as a later release set by hand, keeps it on open,
reopen and close; the milestone is given only to an item that has none. Removal is
the exception: an unmerged or not-planned close takes away whichever milestone the
item holds, since work closed without shipping belongs to no release.

The run re-reads the item through the API rather than trusting the event payload.
Where no open milestone has the title, the item is left without one and the run
emits a ``::warning::``: the script never creates a milestone, since opening the
next one is a release step done by hand. A removal needs no milestone, so it goes
ahead regardless.

A ``workflow_dispatch`` backfills: it applies the same rule to every item, open or
closed, with no milestone whose number is above the highest-numbered item of the
previous release's milestone, the newest of the ``PyPCAPKit v<major>.<minor>``
milestones below the current one. Items at or below that bound belong to releases
already made, and those among them without a milestone were left so by hand. With
no earlier milestone to bound it, the backfill warns and changes nothing. Its
``dry_run`` input is ticked by default, so a dispatch reports what it would change
until it is unticked. Its updates go out a second apart, under GitHub's secondary
rate limit on content-creating requests, and a request refused with a rate limit is
retried a bounded number of times, after the ``Retry-After`` GitHub sends or an
exponential backoff from a minute. The ``Set the milestone`` step picks the mode.

Runs on one item share a concurrency group with ``queue: max``, as Project Status's
do, so a quick open, close and reopen never overlap and none is cancelled. Queue
order is not guaranteed, but each run re-reads the item, so the last to execute
writes its state as it then stands.

Coverage (:issue:`1063`)
-------------------------------------------------------------------------

Expand Down Expand Up @@ -1722,8 +1788,8 @@ script it flags. The step's run summary counts the findings by actionlint rule a
by shellcheck code.

The step's ``-ignore`` pattern, which matches a message, not a file, hides a false
positive: ``concurrency.queue``, which ``project-status.yml`` sets and GitHub
documents, is a key no actionlint release knows yet (`rhysd/actionlint#657
positive: ``concurrency.queue``, which ``project-status.yml`` and ``milestone.yml``
set and GitHub documents, is a key no actionlint release knows yet (`rhysd/actionlint#657
<https://github.com/rhysd/actionlint/issues/657>`__). The pattern goes once a
release that knows it is pinned.

Expand Down
3 changes: 2 additions & 1 deletion tests/project/test_actions_helper_2047.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
HELPER = ROOT / 'util' / '_actions.py'
#: The scripts that import the helper.
SCRIPTS = ('apt_packages', 'check_suite_report', 'ci_timing', 'classify_changes', 'codeql_report',
'coverage_report', 'discussion_report', 'leg_summary', 'pip_install', 'project_status',
'coverage_report', 'discussion_report', 'leg_summary', 'milestone', 'pip_install', 'project_status',
'python_versions', 'regenerate_samples', 'release_conda', 'release_published', 'release_reset_build',
'release_status', 'release_tags', 'release_tiers', 'release_upload', 'superseded',
'update_python_versions', 'vendor_code_changed', 'vendor_test_gate', 'verdict_covers_checkout')
Expand All @@ -63,6 +63,7 @@
'coverage_report': (['--help'], {}),
'discussion_report': (['--help'], {}),
'leg_summary': (['ktlo'], {'LEG': 'Python 3.8 (KTLO, non-blocking)', 'STEPS': '{}'}),
'milestone': (['--help'], {}),
'pip_install': (['--help'], {}),
'project_status': (['--help'], {}),
'python_versions': (['latest'], {}),
Expand Down
Loading
Loading