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
49 changes: 45 additions & 4 deletions .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
actions: read
steps:
- uses: actions/create-github-app-token@v2
id: app-token
Expand All @@ -36,8 +43,42 @@ 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@v7
with:
script: |
const restoreCache = require('./scripts/restore-cache.js');
await restoreCache({
github,
context,
core,
artifactName: 'retire-cache',
dir: '.cache',
});
- 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@v7
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@v4
with:
name: retire-cache
path: .cache/retire-cache.json
if-no-files-found: error
overwrite: true
Comment thread
philiptaron marked this conversation as resolved.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.tmp
.cache
43 changes: 32 additions & 11 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ 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 needs Node.js (`pkgs.nodejs`) and uses the `gh` CLI for API access instead.

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

Expand All @@ -102,51 +105,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.
51 changes: 51 additions & 0 deletions scripts/restore-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Restores the activity cache artifact uploaded by the previous successful
// run of the retire workflow, for use by scripts/retire.js.
// 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');
const { execFileSync } = require('child_process');

module.exports = async function restoreCache({ github, context, core, artifactName, dir }) {
const { owner, repo } = context.repo;
try {
const runs = (
await github.request(`GET /repos/${owner}/${repo}/actions/workflows/retire.yml/runs`, {
status: 'success',
per_page: 1,
})
).data.workflow_runs;
if (runs.length === 0) {
core.info('No previous successful run, 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;
}

// The artifact download endpoint always serves a zip archive
const zip = (
await github.request(`GET /repos/${owner}/${repo}/actions/artifacts/${artifact.id}/zip`)
).data;
fs.mkdirSync(dir, { recursive: true });
const zipPath = path.join(dir, `${artifactName}.zip`);
fs.writeFileSync(zipPath, Buffer.from(zip));
execFileSync('unzip', ['-o', zipPath, '-d', dir], { stdio: 'inherit' });
fs.rmSync(zipPath);
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-compatible 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