Skip to content
Open
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
27 changes: 26 additions & 1 deletion .github/workflows/retire.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ on:

permissions: {}

# Runs read the activity cache artifact of the previous run, so they must not overlap.
concurrency:
group: retire

jobs:
retire:
name: retire
runs-on: ubuntu-latest
permissions:
# To download the activity cache artifact from the previous run (.csv)
actions: read
steps:
- uses: actions/create-github-app-token@v2
id: app-token
Expand All @@ -36,8 +43,26 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
# Fetch full history to determine when committers were added
fetch-depth: 0
- name: Restore activity cache
env:
GH_TOKEN: ${{ github.token }}
run: |
runId=$(gh run list --workflow=retire.yml --status=success --limit 1 --json databaseId --jq '.[0].databaseId // empty')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would benefit from pipefail (etc), which we can enable throughout by setting shell: bash explicitly somewhere:

https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell

(Ideally, set the default at the top of the workflow, otherwise set shell: on relevant steps)

if [[ -n "$runId" ]] && gh run download "$runId" --name retire-cache --dir .cache; then
echo "Restored activity cache from run $runId"
else
echo "No previous activity cache, starting fresh"
fi
Comment on lines +46 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is trivial enough as bash, but if written as a github-script action, we'd gain more explicit handling of error modes (e.g. the API request failing).

Having this small step be github-script could be a small win and set a precedent for later rewriting other scripts too?

Fine as is, though.

- name: Run script
run: scripts/retire.sh NixOS nixpkgs nixpkgs-committers members "yesterday 1 month ago" "1 year ago"
run: scripts/retire.sh NixOS nixpkgs nixpkgs-committers members "yesterday 1 month ago" "1 year ago" "7 days ago"
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PROD: "1"
CACHE_FILE: .cache/retire-cache.csv
- name: Save activity cache
uses: actions/upload-artifact@v4
with:
name: retire-cache
path: .cache/retire-cache.csv
if-no-files-found: error
overwrite: true
Comment on lines +65 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: could we do archive: false so the CSV becomes readable in a browser? There should be no need to zip archive it.

This also means we don't need name, as it's ignored when not archiving, path's basename is used instead.


Let's also explicitly set retention-days. The default (and maximum) is 90, but if we schedule this workflow daily we can probably reduce artifact retention to a week or two?

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.tmp
.cache
41 changes: 29 additions & 12 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# Nixpkgs Committer management script testing

The recommended way to test these scripts is to run them in a GitHub test organisation with the right setup.
Since creating your own takes some time, you can just ask @infinisil to get added to his test setup instead,
whose identifiers will be used here.
Since creating your own takes some time, you can just ask @infinisil to get added to his test setup instead, whose identifiers will be used here.

## Setup

Expand Down Expand Up @@ -102,51 +101,69 @@ git push -f -u origin HEAD

### Test sequence

The following sequence tests all code paths:
The following sequence tests all code paths.

The `CONFIRM_CUTOFF` argument is passed as `now` so that a single run is enough to open a retirement PR.
In CI it is set to `7 days ago` instead, which together with the `CACHE_FILE` activity cache (persisted between runs as a workflow artifact) requires a week of daily runs to agree before a PR is opened.

1. Run the script with the `active` repo argument to simulate CI running without inactive users:
```bash
scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now
scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now
```

Check that no PR would be opened.
1. Run the previous command again with `CACHE_FILE` set:
```bash
CACHE_FILE=.tmp-cache.csv scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now
CACHE_FILE=.tmp-cache.csv scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now
rm .tmp-cache.csv
```

Check that the first run writes your activity to the cache file and that the second run skips the `/commits` query for your user because of it.
1. Run the script with the `empty` repo argument to simulate CI running with inactive users:

```bash
scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now
scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now now
```

Check that it would only create a PR for your own user and not the "new-committer-1" or "new-committer-2" user.
Also check that with a `CONFIRM_CUTOFF` in the past (as used in CI), no PR would be opened yet:

```bash
scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now '7 days ago'
```

Check that it would only create a PR for your own user and not the "new-committer-1" or "new-committer-2" user before running it again with `PROD=1` to actually do it:
Then run it again with `PROD=1` to actually do it:

```bash
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now now
```

Check that it created the PR appropriately, including assigning the "retirement" label.
You can undo this step by closing the PR.
1. Run it again to simulate CI running again later:
```bash
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now now
```
Check that no other PR is opened.
1. Run it again with `now` as the notice cutoff date to simulate the time interval passing:
```bash
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now now
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now now now
```
Check that it undrafted the previous PR and posted an appropriate comment.
1. Run it again to simulate CI running again later:
```bash
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now now
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now now now
```
Check that no other PR is opened.
1. Reset by marking the PR as a draft again, then run it again with the `active` repo argument to simulate activity during the time interval:
```bash
PROD=1 scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test now now
PROD=1 scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test now now now
```
Check that it gets undrafted with a comment listing the new activity.
1. Close the PR, then run the script again with no activity and for an earlier close cutoff, simulating that the retirement was delayed:
```bash
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now '1 day ago'
PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now '1 day ago' now
```

Check that no other PR is opened.
149 changes: 131 additions & 18 deletions scripts/retire.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ source "$(dirname -- "${BASH_SOURCE[0]}")"/common.sh
shopt -s nullglob

usage() {
log "Usage: $0 ORG ACTIVITY_REPO MEMBER_REPO DIR NOTICE_CUTOFF CLOSE_CUTOFF"
log "Usage: $0 ORG ACTIVITY_REPO MEMBER_REPO DIR NOTICE_CUTOFF CLOSE_CUTOFF CONFIRM_CUTOFF"
log ""
log "Optionally set CACHE_FILE to a path for persisting activity observations between runs."
exit 1
}

Expand All @@ -15,14 +17,30 @@ MEMBER_REPO=${3:-$(usage)}
DIR=${4:-$(usage)}
NOTICE_CUTOFF=${5:-$(usage)}
CLOSE_CUTOFF=${6:-$(usage)}
CONFIRM_CUTOFF=${7:-$(usage)}

mainBranch=$(git branch --show-current)
noticeCutoff=$(date --date="$NOTICE_CUTOFF" +%s)

nowEpoch=$(date +%s)

# People that received the commit bit after this date won't be retired
newCutoff=$(date --date="1 year ago" +%s)
# Users whose retirement PRs were closed after this date won't be retired
closeCutoff=$(date --date="$CLOSE_CUTOFF" +%s)
# Users first observed as inactive after this date won't be retired yet;
# they keep being checked until the observation is old enough.
# This prevents a single spurious empty response from the /commits endpoint
# from causing an accidental retirement PR (see issue #100).
confirmCutoff=$(date --date="$CONFIRM_CUTOFF" +%s)

# People are considered active if they merged a PR after this date
yearAgo=$(date --date='1 year ago' +%s)
# Cached merges newer than this are trusted without querying GitHub again.
# The gap until the 1-year mark means the /commits endpoint gets queried
# for about a month of daily runs before a retirement PR can be opened,
# so a retirement is backed by many independent queries agreeing.
queryCutoff=$(date --date='11 months ago' +%s)

# We need to know when people received their commit bit to avoid retiring them within the first year.
# For now this is done either with the git creation date of the file, or its contents:
Expand Down Expand Up @@ -54,6 +72,33 @@ else
trap 'rm -rf "$tmp"' exit
fi

# Activity observed by previous runs, so that the unreliable /commits
# endpoint doesn't need to be trusted on a single response (see issue #100).
# The cache only holds positive evidence (merges the API actually returned)
# plus the time somebody was first observed as inactive,
# so a spurious empty response can never make a cached-active user look inactive.
# Bump this version to invalidate all previously cached data.
cacheVersion=1
declare -A cachedMergeEpoch cachedMergePr cachedFirstInactive
if [[ -n "${CACHE_FILE:-}" ]]; then
CACHE_FILE=$(realpath -m -- "$CACHE_FILE")
if [[ -f "$CACHE_FILE" ]] && [[ $(head -n 1 "$CACHE_FILE") == "# retire-cache v$cacheVersion" ]]; then
while IFS=, read -r cLogin cMergeEpoch cMergePr cFirstInactive; do
[[ "$cLogin" == '#'* ]] && continue
cachedMergeEpoch[$cLogin]=$cMergeEpoch
cachedMergePr[$cLogin]=$cMergePr
cachedFirstInactive[$cLogin]=$cFirstInactive
done < "$CACHE_FILE"
log "Loaded activity cache with ${#cachedMergeEpoch[@]} entries from $CACHE_FILE"
else
log "No usable activity cache at $CACHE_FILE, starting fresh"
fi
fi

# Matches the merge lines produced by the jq filter below,
# capturing the merge date and PR number for the cache
mergeLineRegex='^- `([^`]+)` .*(#[0-9]+)$'
Comment on lines +98 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a little brittle. Is there a way we can pass the date+number values around in a way that doesn't require string parsing to extract them again?


mkdir -p "$DIR"
cd "$DIR"
for login in *; do
Expand Down Expand Up @@ -108,22 +153,72 @@ for login in *; do
continue
fi

PR_PREFIX="$ORG/$ACTIVITY_REPO" \
trace gh api -X GET /repos/"$ORG"/"$ACTIVITY_REPO"/commits \
-f since="$(date --date='1 year ago' --iso-8601=seconds)" \
-f author="$login" \
-f committer=web-flow \
-f per_page=100 \
--jq '.[] |
# PR merge commits have two parents. We also check it’s an
# authentic GitHub commit, because… why not?
select((.parents | length) == 2 and .commit.verification.verified) |
(.commit.message | capture(" \\((?<pr>#[0-9]+)\\)$").pr) as $pr |
"- `\(.commit.committer.date)` – \(env.PR_PREFIX)\($pr)"' \
> "$tmp/$login"
mergeCount=$(wc -l <"$tmp/$login")
# What previous runs observed about this person's merge activity
mergeEpoch=${cachedMergeEpoch[$login]:-0}
mergePr=${cachedMergePr[$login]:-}
firstInactive=${cachedFirstInactive[$login]:-0}

: > "$tmp/$login"
mergeCount=0
queried=
if [[ "$prState" != open ]] && (( mergeEpoch > queryCutoff )); then
# Recent cached activity already rules out retirement,
# no need to bother the unreliable /commits endpoint
:
else
queried=1
if [[ "$prState" == open ]]; then
# The PR comment lists activity from the whole past year
sinceEpoch=$yearAgo
else
# Otherwise only merges newer than the cached one are of interest
sinceEpoch=$(( mergeEpoch > yearAgo ? mergeEpoch + 1 : yearAgo ))
fi
PR_PREFIX="$ORG/$ACTIVITY_REPO" \
trace gh api -X GET /repos/"$ORG"/"$ACTIVITY_REPO"/commits \
-f since="$(date --date=@"$sinceEpoch" --iso-8601=seconds)" \
-f author="$login" \
-f committer=web-flow \
-f per_page=100 \
--jq '.[] |
# PR merge commits have two parents. We also check it’s an
# authentic GitHub commit, because… why not?
select((.parents | length) == 2 and .commit.verification.verified) |
(.commit.message | capture(" \\((?<pr>#[0-9]+)\\)$").pr) as $pr |
"- `\(.commit.committer.date)` – \(env.PR_PREFIX)\($pr)"' \
> "$tmp/$login"
mergeCount=$(wc -l <"$tmp/$login")
fi

if (( mergeCount > 0 )); then
# Cache the newest merge (first line, the endpoint returns newest first)
if [[ $(head -n 1 "$tmp/$login") =~ $mergeLineRegex ]]; then
newestMergeEpoch=$(date --date="${BASH_REMATCH[1]}" +%s)
if (( newestMergeEpoch > mergeEpoch )); then
mergeEpoch=$newestMergeEpoch
mergePr=${BASH_REMATCH[2]}
fi
fi
firstInactive=0
elif [[ -n "$queried" ]] && (( firstInactive == 0 )); then
# An empty response might just be a timeout of the endpoint (issue #100),
# so this only starts the inactivity clock; retirement additionally needs
# all queries until the confirmation cutoff to keep coming back empty
firstInactive=$nowEpoch
fi

# Remember the observations for the cache written at the end of the run
cachedMergeEpoch[$login]=$mergeEpoch
cachedMergePr[$login]=$mergePr
cachedFirstInactive[$login]=$firstInactive

if [[ "$prState" == open ]]; then
if (( mergeCount == 0 && mergeEpoch > yearAgo )); then
# The query came back empty even though the cache proves activity
# (issue #100), so reconstruct the activity list from the cache
echo "- \`$(date --date=@"$mergeEpoch" --iso-8601=seconds)\` – $ORG/$ACTIVITY_REPO$mergePr" > "$tmp/$login"
mergeCount=1
fi
# If there is an open PR already
prNumber=$(jq .number <<< "$prInfo")
epochCreatedAt=$(jq '.created_at | fromdateiso8601' <<< "$prInfo")
Expand Down Expand Up @@ -156,7 +251,15 @@ for login in *; do
else
log "$login has a retirement PR pending"
fi
elif (( mergeCount <= 0 )); then
elif (( mergeEpoch > yearAgo )); then
if (( mergeCount > 0 )); then
log "$login is active with $mergeCount new merges"
else
log "$login is active, last known merge $mergePr on $(date --date=@"$mergeEpoch" --iso-8601=seconds) (cached)"
fi
elif (( firstInactive > confirmCutoff )); then
log "$login appears inactive since $(date --date=@"$firstInactive" --iso-8601=seconds), continuing to check before opening a PR"
else
log "$login has become inactive, opening a PR"
# If there is no PR yet, but they have become inactive
(
Expand Down Expand Up @@ -189,8 +292,18 @@ for login in *; do
/repos/"$ORG"/"$MEMBER_REPO"/issues/"$prNumber"/labels \
-f "labels[]=retirement" >/dev/null
)
else
log "$login is active with $mergeCount merges"
fi
log ""
done

if [[ -n "${CACHE_FILE:-}" ]]; then
mkdir -p "$(dirname -- "$CACHE_FILE")"
{
echo "# retire-cache v$cacheVersion"
for login in *; do
echo "$login,${cachedMergeEpoch[$login]:-0},${cachedMergePr[$login]:-},${cachedFirstInactive[$login]:-0}"
done
} > "$CACHE_FILE".tmp
mv "$CACHE_FILE".tmp "$CACHE_FILE"
log "Wrote activity cache to $CACHE_FILE"
fi
Loading