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
6 changes: 5 additions & 1 deletion .github/workflows/nomination.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ permissions:
issues: write
pull-requests: write

# An explicit `shell: bash` runs steps with `-eo pipefail`, not just `-e`
defaults:
run:
shell: bash

jobs:
process:
name: Check
Expand All @@ -20,7 +25,6 @@ jobs:
uses: actions/checkout@v7
- name: Process nomination
run: |
set -o pipefail
gh api "repos/$REPOSITORY/pulls/$PR_NUMBER/files" \
--jq '.[] | "\(.status) \(.filename)"' \
| scripts/nomination.sh members "$REPOSITORY" "$PR_NUMBER" "$ANNOUNCEMENT_ISSUE_NUMBER"
Expand Down
56 changes: 52 additions & 4 deletions .github/workflows/retire.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ on:

permissions: {}

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

# An explicit `shell: bash` runs steps with `-eo pipefail`, not just `-e`
defaults:
run:
shell: bash

jobs:
retire:
name: retire
runs-on: ubuntu-latest
permissions:
# To download the activity cache artifact from the previous run
actions: read
steps:
- uses: actions/create-github-app-token@v3
id: app-token
Expand All @@ -36,8 +48,44 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
# Fetch full history to determine when committers were added
fetch-depth: 0
- name: Restore activity cache
uses: actions/github-script@v9
with:
script: |
const restoreCache = require('./scripts/restore-cache.js');
await restoreCache({
github,
context,
core,
artifactName: 'retire-cache.json',
dir: '.cache',
branch: 'main',
});
- name: Run script
run: scripts/retire.sh NixOS nixpkgs nixpkgs-committers members "yesterday 1 month ago" "1 year ago"
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PROD: "1"
uses: actions/github-script@v9
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const retire = require('./scripts/retire.js');
await retire({
github,
options: {
org: 'NixOS',
activityRepo: 'nixpkgs',
memberRepo: 'nixpkgs-committers',
dir: 'members',
noticeCutoffSpec: 'yesterday 1 month ago',
closeCutoffSpec: '1 year ago',
confirmCutoffSpec: '7 days ago',
cacheFile: '.cache/retire-cache.json',
prod: true,
},
});
- name: Save activity cache
uses: actions/upload-artifact@v7
with:
path: .cache/retire-cache.json
# Upload the file as-is, so it's readable in the browser and needs no unzipping.
# The artifact is then named after the file.
archive: false
if-no-files-found: error
5 changes: 5 additions & 0 deletions .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:

permissions: {}

# An explicit `shell: bash` runs steps with `-eo pipefail`, not just `-e`
defaults:
run:
shell: bash

jobs:
sync:
name: sync
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.tmp
.cache
44 changes: 33 additions & 11 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ To test:
PROD=1 scripts/nomination.sh members infinisil-test-org/nixpkgs-committers 33 30 <<< "added members/infinisil"
```

## Testing `retire.sh`
## Testing `retire.js`

In CI, `retire.js` runs via [`actions/github-script`](https://github.com/actions/github-script).
Locally it can be run with `scripts/retire-local.js`, which uses the `gh` CLI for API access instead.
Run `nix-shell` in the repository root to get Node.js and `gh`; you still need to authenticate with `gh auth login`.

This script has external effects and as such needs a bit more care when testing.

Expand All @@ -102,51 +106,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-local.js 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.json scripts/retire-local.js infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now
CACHE_FILE=.tmp-cache.json scripts/retire-local.js infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now
rm .tmp-cache.json
```

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-local.js 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-local.js 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-local.js 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-local.js 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-local.js 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-local.js 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-local.js 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-local.js infinisil-test-org empty nixpkgs-committers members-test now '1 day ago' now
```

Check that no other PR is opened.
57 changes: 57 additions & 0 deletions scripts/restore-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Restores the activity cache artifact uploaded by the previous successful
// run of the retire workflow on the given branch, for use by scripts/retire.js.
// Runs on other branches (e.g. manual test runs) are ignored.
// Runs in CI via actions/github-script (see .github/workflows/retire.yml).
//
// Any failure here only means starting without a cache, which is safe:
// a missing cache can delay retirements, but never cause one.

'use strict';

const fs = require('fs');
const path = require('path');

module.exports = async function restoreCache({ github, context, core, artifactName, dir, branch }) {
const { owner, repo } = context.repo;
try {
const runs = (
await github.request(`GET /repos/${owner}/${repo}/actions/workflows/retire.yml/runs`, {
status: 'success',
branch,
per_page: 1,
})
).data.workflow_runs;
if (runs.length === 0) {
core.info(`No previous successful run on ${branch}, starting fresh`);
return;
}
const runId = runs[0].id;

const artifacts = (
await github.request(`GET /repos/${owner}/${repo}/actions/runs/${runId}/artifacts`)
).data.artifacts;
const artifact = artifacts.find((a) => a.name === artifactName && !a.expired);
if (!artifact) {
core.warning(`Run ${runId} has no ${artifactName} artifact, starting fresh`);
return;
}

// Despite the endpoint's name, an artifact uploaded with `archive: false`
// downloads as the file itself. A .json file is served as application/json,
// which Octokit parses, but accept raw text or bytes as well.
const { data } = await github.request(
`GET /repos/${owner}/${repo}/actions/artifacts/${artifact.id}/zip`,
);
const contents =
data instanceof ArrayBuffer
? Buffer.from(data)
: typeof data === 'string'
? data
: JSON.stringify(data);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, artifactName), contents);
core.info(`Restored activity cache from run ${runId}`);
} catch (err) {
core.warning(`Could not restore activity cache, starting fresh: ${err.message}`);
}
};
74 changes: 74 additions & 0 deletions scripts/retire-local.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Local runner for scripts/retire.js, providing an Octokit shim
// backed by the `gh` CLI so no npm dependencies are needed.
// See scripts/README.md for the test sequence.

'use strict';

const { execFileSync } = require('child_process');
const retire = require('./retire.js');

function usage() {
console.error(`Usage: ${process.argv[1]} ORG ACTIVITY_REPO MEMBER_REPO DIR NOTICE_CUTOFF CLOSE_CUTOFF CONFIRM_CUTOFF`);
console.error('');
console.error('Optionally set CACHE_FILE to a path for persisting activity observations between runs.');
console.error('Set PROD=1 to actually perform side effects.');
process.exit(1);
}

const [org, activityRepo, memberRepo, dir, noticeCutoffSpec, closeCutoffSpec, confirmCutoffSpec] =
process.argv.slice(2);
if (!confirmCutoffSpec) {
usage();
}

function gh(args, input) {
return execFileSync('gh', args, {
input,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
});
}

const github = {
async request(route, params = {}) {
const [method, pathTemplate] = route.split(' ');
let args;
let input;
if (method === 'GET') {
const query = new URLSearchParams(params).toString();
args = ['api', '-X', 'GET', query ? `${pathTemplate}?${query}` : pathTemplate];
} else {
args = ['api', '-X', method, pathTemplate, '--input', '-'];
input = JSON.stringify(params);
}
const out = gh(args, input);
return { data: out.trim() === '' ? null : JSON.parse(out) };
},
async graphql(query, variables = {}) {
const out = gh(['api', 'graphql', '--input', '-'], JSON.stringify({ query, variables }));
const parsed = JSON.parse(out);
if (parsed.errors) {
throw new Error(JSON.stringify(parsed.errors));
}
return parsed.data;
},
};

retire({
github,
options: {
org,
activityRepo,
memberRepo,
dir,
noticeCutoffSpec,
closeCutoffSpec,
confirmCutoffSpec,
cacheFile: process.env.CACHE_FILE,
prod: Boolean(process.env.PROD),
},
}).catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading