Skip to content

Commit 398d242

Browse files
committed
ci(repo-hygiene): run trusted checks on prs
1 parent 9ec9007 commit 398d242

5 files changed

Lines changed: 116 additions & 66 deletions

File tree

.github/workflows/main.yml

Lines changed: 60 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
name: Repo Hygiene
22

33
on:
4+
pull_request_target:
5+
branches: [main]
6+
types: [opened, synchronize, reopened]
47
pull_request:
58
branches: [main]
69
push:
@@ -12,66 +15,73 @@ permissions:
1215
jobs:
1316
repo-hygiene:
1417
runs-on: ubuntu-latest
18+
if: github.event_name == 'pull_request_target' || github.event_name == 'push'
1519
steps:
16-
- name: Check PR author account age
17-
if: github.event_name == 'pull_request'
18-
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
19-
env:
20-
PR_AUTHOR_MINIMUM_AGE_DAYS: ${{ vars.PR_AUTHOR_MINIMUM_AGE_DAYS || '30' }}
21-
PR_AUTHOR_AGE_ALLOWLIST: ${{ vars.PR_AUTHOR_AGE_ALLOWLIST }}
20+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
2221
with:
23-
script: |
24-
const username = context.payload.pull_request.user.login;
25-
const allowlist = (process.env.PR_AUTHOR_AGE_ALLOWLIST || "PatrickJS,dependabot[bot],Copilot")
26-
.split(",")
27-
.map((entry) => entry.trim().toLowerCase())
28-
.filter(Boolean);
29-
30-
if (allowlist.includes(username.toLowerCase())) {
31-
core.info(`PR author account age check passed: ${username} is allowlisted.`);
32-
return;
33-
}
34-
35-
const minimumAgeDays = Number.parseInt(process.env.PR_AUTHOR_MINIMUM_AGE_DAYS, 10);
36-
if (!Number.isInteger(minimumAgeDays) || minimumAgeDays < 0) {
37-
core.setFailed(`Invalid PR_AUTHOR_MINIMUM_AGE_DAYS: ${process.env.PR_AUTHOR_MINIMUM_AGE_DAYS}`);
38-
return;
39-
}
40-
41-
const { data: user } = await github.rest.users.getByUsername({ username });
42-
const createdTime = Date.parse(user.created_at);
43-
const ageDays = Math.floor((Date.now() - createdTime) / 86400000);
44-
45-
if (ageDays < minimumAgeDays) {
46-
core.setFailed(
47-
`PR author account is too new: ${username} is ${ageDays} day(s) old; minimum is ${minimumAgeDays} day(s).`,
48-
);
49-
return;
50-
}
51-
52-
core.info(
53-
`PR author account age check passed: ${username} is ${ageDays} day(s) old; minimum is ${minimumAgeDays} day(s).`,
54-
);
22+
node-version: 20
5523

56-
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
24+
- name: Checkout trusted base checks
25+
if: github.event_name == 'pull_request_target'
26+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
5727
with:
28+
ref: ${{ github.event.pull_request.base.ref }}
29+
path: .trusted-base
5830
fetch-depth: 0
31+
persist-credentials: false
5932

60-
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
33+
- name: Check PR author account age
34+
if: github.event_name == 'pull_request_target'
35+
env:
36+
GITHUB_TOKEN: ${{ github.token }}
37+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
38+
PR_AUTHOR_MINIMUM_AGE_DAYS: ${{ vars.PR_AUTHOR_MINIMUM_AGE_DAYS || '30' }}
39+
PR_AUTHOR_AGE_ALLOWLIST: ${{ vars.PR_AUTHOR_AGE_ALLOWLIST || 'PatrickJS,dependabot[bot],Copilot' }}
40+
run: |
41+
node .trusted-base/scripts/check-pr-author.mjs \
42+
--username "$PR_AUTHOR" \
43+
--minimum-age-days "$PR_AUTHOR_MINIMUM_AGE_DAYS" \
44+
--allowlist "$PR_AUTHOR_AGE_ALLOWLIST"
45+
46+
- name: Checkout pull request content
47+
if: github.event_name == 'pull_request_target'
48+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
6149
with:
62-
node-version: 20
50+
repository: ${{ github.event.pull_request.head.repo.full_name }}
51+
ref: ${{ github.event.pull_request.head.sha }}
52+
path: pr
53+
fetch-depth: 0
54+
persist-credentials: false
6355

64-
- name: Enable pnpm
56+
- name: Determine pull request changed files
57+
if: github.event_name == 'pull_request_target'
58+
shell: bash
59+
env:
60+
BASE_REF: ${{ github.event.pull_request.base.ref }}
6561
run: |
66-
corepack enable
67-
corepack prepare pnpm@10.20.0 --activate
62+
git -C pr remote add trusted-base "$GITHUB_SERVER_URL/${{ github.repository }}.git"
63+
git -C pr fetch --no-tags trusted-base "$BASE_REF"
64+
base="$(git -C pr rev-parse FETCH_HEAD)"
65+
66+
git -C pr diff --name-only "$base"...HEAD > pr/.changed-files
67+
git -C pr diff --unified=0 "$base"...HEAD -- README.md > pr/.readme.diff || true
68+
69+
- name: Run trusted repo hygiene checks
70+
if: github.event_name == 'pull_request_target'
71+
run: node .trusted-base/scripts/check-repo-hygiene.mjs --root "$GITHUB_WORKSPACE/pr" --changed-files .changed-files --diff-file .readme.diff
6872

69-
- name: Determine changed files
73+
- name: Checkout push content
74+
if: github.event_name == 'push'
75+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
76+
with:
77+
fetch-depth: 0
78+
persist-credentials: false
79+
80+
- name: Determine push changed files
81+
if: github.event_name == 'push'
7082
shell: bash
7183
run: |
72-
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
73-
base="${{ github.event.pull_request.base.sha }}"
74-
elif [[ -n "${{ github.event.before }}" && "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then
84+
if [[ -n "${{ github.event.before }}" && "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then
7585
base="${{ github.event.before }}"
7686
else
7787
base="$(git rev-list --max-parents=0 HEAD)"
@@ -80,25 +90,14 @@ jobs:
8090
git diff --name-only "$base"...HEAD > .changed-files
8191
git diff --unified=0 "$base"...HEAD -- README.md > .readme.diff || true
8292
83-
- name: Checkout trusted base checks
84-
if: github.event_name == 'pull_request'
85-
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
86-
with:
87-
ref: ${{ github.event.pull_request.base.sha }}
88-
path: .trusted-base
89-
fetch-depth: 1
90-
91-
- name: Run trusted repo hygiene checks
92-
if: github.event_name == 'pull_request'
93-
run: node .trusted-base/scripts/check-repo-hygiene.mjs --root "$GITHUB_WORKSPACE" --changed-files .changed-files --diff-file .readme.diff
94-
9593
- name: Run repo hygiene checks
96-
if: github.event_name != 'pull_request'
94+
if: github.event_name == 'push'
9795
run: node scripts/check-repo-hygiene.mjs --changed-files .changed-files --diff-file .readme.diff
9896

9997
awesome-lint:
10098
name: awesome-lint
10199
runs-on: ubuntu-latest
100+
if: github.event_name != 'pull_request_target'
102101
steps:
103102
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
104103
with:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.idea
22
awesome-claude-code
33
node_modules/
4+
/docs/goals

scripts/check-pr-author.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ try {
2727

2828
if (ageDays < minimumAgeDays) {
2929
fail(
30-
`PR author account is too new: ${username} is ${ageDays} day(s) old; minimum is ${minimumAgeDays} day(s).`,
30+
[
31+
`PR author account is too new: ${username} is ${ageDays} day(s) old; minimum is ${minimumAgeDays} day(s).`,
32+
"This repo trust policy blocks very fresh GitHub accounts because they are higher risk for spam, prompt-injection, promotional, and other abuse risk.",
33+
"Maintainers can allow trusted exceptions with PR_AUTHOR_AGE_ALLOWLIST.",
34+
].join(" "),
3135
);
3236
}
3337

scripts/check-pr-author.test.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ test("blocks authors whose accounts are newer than the minimum age", () => {
4242
assert.match(result.stderr, /PR author account is too new/);
4343
assert.match(result.stderr, /new-contributor/);
4444
assert.match(result.stderr, /8 day/);
45+
assert.match(result.stderr, /repo trust policy/);
46+
assert.match(result.stderr, /spam, prompt-injection, promotional, and other abuse risk/);
47+
});
48+
49+
test("passes authors exactly at the minimum account age", () => {
50+
const result = run([
51+
"--username",
52+
"boundary-contributor",
53+
"--created-at",
54+
"2026-04-11T12:00:00Z",
55+
"--minimum-age-days",
56+
"30",
57+
]);
58+
59+
assert.equal(result.status, 0, result.stderr + result.stdout);
60+
assert.match(result.stdout, /boundary-contributor is 30 day\(s\) old/);
4561
});
4662

4763
test("allows explicitly allowlisted authors even when the account is new", () => {
@@ -94,3 +110,17 @@ test("fails when account creation date is invalid", () => {
94110
assert.equal(result.status, 1);
95111
assert.match(result.stderr, /Invalid account creation date/);
96112
});
113+
114+
test("fails when minimum account age is invalid", () => {
115+
const result = run([
116+
"--username",
117+
"bad-config",
118+
"--created-at",
119+
"2026-01-01T12:00:00Z",
120+
"--minimum-age-days",
121+
"not-a-number",
122+
]);
123+
124+
assert.equal(result.status, 1);
125+
assert.match(result.stderr, /Invalid minimum age/);
126+
});

scripts/workflow-security.test.mjs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,41 @@ import test from "node:test";
55
const workflow = readFileSync(new URL("../.github/workflows/main.yml", import.meta.url), "utf8");
66
const codeowners = readFileSync(new URL("../.github/CODEOWNERS", import.meta.url), "utf8");
77
const pullRequestTemplate = readFileSync(new URL("../.github/pull_request_template.md", import.meta.url), "utf8");
8+
const repoHygieneJob = workflow.slice(workflow.indexOf(" repo-hygiene:"), workflow.indexOf("\n awesome-lint:"));
9+
const awesomeLintJob = workflow.slice(workflow.indexOf(" awesome-lint:"));
810

911
test("repo hygiene workflow grants read-only contents permission", () => {
1012
assert.match(workflow, /^permissions:\n\s+contents:\s+read\s*$/m);
1113
assert.doesNotMatch(workflow, /contents:\s*write/);
1214
});
1315

14-
test("pull request hygiene runs the trusted base copy of the script", () => {
15-
assert.match(workflow, /path:\s+\.trusted-base/);
16+
test("repo hygiene uses a trusted pull_request_target preflight", () => {
1617
assert.match(
1718
workflow,
18-
/node \.trusted-base\/scripts\/check-repo-hygiene\.mjs --root "\$GITHUB_WORKSPACE" --changed-files \.changed-files --diff-file \.readme\.diff/,
19+
/^ pull_request_target:\n\s+branches:\s+\[main\]\n\s+types:\s+\[opened, synchronize, reopened\]$/m,
1920
);
21+
assert.match(repoHygieneJob, /if:\s+github\.event_name == 'pull_request_target' \|\| github\.event_name == 'push'/);
22+
assert.match(repoHygieneJob, /path:\s+\.trusted-base/);
23+
assert.match(
24+
repoHygieneJob,
25+
/node \.trusted-base\/scripts\/check-repo-hygiene\.mjs --root "\$GITHUB_WORKSPACE\/pr" --changed-files \.changed-files --diff-file \.readme\.diff/,
26+
);
27+
assert.doesNotMatch(repoHygieneJob, /github\.event\.pull_request\.base\.sha/);
2028
});
2129

2230
test("workflow has an explicit awesome-lint job for every pull request", () => {
23-
assert.match(workflow, /^ awesome-lint:\n\s+name:\s+awesome-lint\n\s+runs-on:\s+ubuntu-latest$/m);
31+
assert.match(awesomeLintJob, /^ awesome-lint:\n\s+name:\s+awesome-lint\n\s+runs-on:\s+ubuntu-latest$/m);
2432
assert.match(workflow, /^ pull_request:\n\s+branches:\s+\[main\]$/m);
2533
});
2634

35+
test("repo hygiene does not execute contributor-controlled code", () => {
36+
assert.match(repoHygieneJob, /node \.trusted-base\/scripts\/check-pr-author\.mjs/);
37+
assert.match(repoHygieneJob, /path:\s+pr/);
38+
assert.match(repoHygieneJob, /persist-credentials:\s+false/);
39+
assert.doesNotMatch(repoHygieneJob, /\bpnpm\s+(install|dlx|run)\b/);
40+
assert.doesNotMatch(repoHygieneJob, /\bcorepack\b/);
41+
});
42+
2743
test("pull requests run trusted awesome-list checks", () => {
2844
assert.match(workflow, /node \.trusted-base\/scripts\/check-awesome-list\.mjs --root "\$GITHUB_WORKSPACE"/);
2945
assert.match(workflow, /pnpm dlx awesome-lint "\$GITHUB_WORKSPACE\/README\.md"/);

0 commit comments

Comments
 (0)