Skip to content

Commit 7c69d1b

Browse files
CopilotTylerJDev
andauthored
Add Dependabot changeset policy
Co-authored-by: TylerJDev <26746305+TylerJDev@users.noreply.github.com>
1 parent f6660db commit 7c69d1b

10 files changed

Lines changed: 450 additions & 644 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Check for changeset
2+
3+
on:
4+
pull_request:
5+
types:
6+
- opened
7+
- reopened
8+
- synchronize
9+
- labeled
10+
- unlabeled
11+
12+
permissions:
13+
contents: read
14+
pull-requests: read
15+
16+
jobs:
17+
check:
18+
if: github.event.pull_request.user.login != 'dependabot[bot]'
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Check for changeset or skip label
22+
env:
23+
GH_TOKEN: ${{ github.token }}
24+
PR_NUMBER: ${{ github.event.pull_request.number }}
25+
run: |
26+
if jq -e '.pull_request.labels[].name == "skip changeset"' "$GITHUB_EVENT_PATH" >/dev/null; then
27+
echo 'The "skip changeset" label is present.'
28+
exit 0
29+
fi
30+
31+
changesets=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' | grep -E '^\.changeset/[^/]+\.md$' | grep -Ev '^\.changeset/(README\.md|dependabot-[0-9]+\.md)$' || true)
32+
if [ -n "$changesets" ]; then
33+
echo 'The ".changeset/*.md" file pattern matched the changed files of the pull request.'
34+
exit 0
35+
fi
36+
37+
echo 'No changeset found. If these changes should not result in a new version, apply the "skip changeset" label to this pull request. If these changes should result in a version bump, run "npx changeset" and commit the generated file.'
38+
exit 1
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Dependabot changeset
2+
3+
on:
4+
pull_request_target:
5+
types:
6+
- opened
7+
- reopened
8+
- synchronize
9+
10+
permissions:
11+
contents: write
12+
pull-requests: read
13+
security-events: read
14+
15+
jobs:
16+
changeset:
17+
if: github.event.pull_request.user.login == 'dependabot[bot]'
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Verify Dependabot author
21+
run: |
22+
if [ "${{ github.event.pull_request.user.login }}" != "dependabot[bot]" ]; then
23+
echo "This workflow only modifies Dependabot-authored pull requests."
24+
exit 1
25+
fi
26+
27+
- name: Create GitHub App token
28+
id: app-token
29+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
30+
with:
31+
app-id: ${{ vars.PRIMER_APP_ID_SHARED }}
32+
private-key: ${{ secrets.PRIMER_APP_PRIVATE_KEY_SHARED }}
33+
permission-contents: write
34+
permission-pull-requests: read
35+
permission-vulnerability-alerts: read
36+
37+
- name: Fetch Dependabot metadata
38+
id: metadata
39+
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b
40+
with:
41+
github-token: ${{ steps.app-token.outputs.token }}
42+
alert-lookup: true
43+
44+
- name: Check out trusted base code
45+
uses: actions/checkout@v4
46+
with:
47+
ref: ${{ github.event.pull_request.base.sha }}
48+
persist-credentials: false
49+
50+
- name: Set up Node
51+
uses: actions/setup-node@v4
52+
with:
53+
node-version: 22
54+
55+
- name: Apply Dependabot changeset policy
56+
run: node scripts/dependabot-changeset.mjs
57+
env:
58+
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
59+
DEPENDABOT_METADATA_JSON: ${{ toJson(steps.metadata.outputs) }}

.github/workflows/pr.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ jobs:
2424
run: npm run build
2525
- name: Run test
2626
run: npm run test
27-
- name: Run release script tests
28-
run: npm run test:release-script
27+
- name: Run Dependabot changeset policy tests
28+
run: npm run test:dependabot-changeset

.github/workflows/release.yml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ jobs:
1919
steps:
2020
- uses: actions/checkout@v4
2121
with:
22-
# Full history and tags are required so the release-preparation
23-
# script can compare against the previous release tag.
22+
# Full history and tags let Changesets generate GitHub changelogs
23+
# with the correct commits.
2424
fetch-depth: 0
2525

2626
- uses: actions/setup-node@v4
@@ -34,14 +34,10 @@ jobs:
3434
- name: Run tests
3535
run: npm test
3636

37-
- name: Prepare release (synthetic changeset for unreleased changes)
38-
run: npm run prepare-release
39-
4037
- name: Create release pull request or publish
4138
uses: changesets/action@v2
4239
with:
43-
pr-title: 'Release tracking'
44-
commit-message: 'chore: version packages'
45-
publish-script: npm run release
40+
title: 'Release tracking'
41+
publish: npm run release
4642
env:
4743
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,12 @@ npm test
7575

7676
This project uses [Changesets](https://github.com/changesets/changesets) to manage versioning, changelogs, and publishing to npm.
7777

78-
- **Explicit changesets**: For a user-facing change, run `npx changeset`, describe the change, and choose a `patch`, `minor`, or `major` bump. Commit the generated changeset with the change. Explicit changesets take precedence over automatic release entries and are released immediately.
79-
- **Automatic patch changesets**: On pushes to `main`, the [`release` workflow](.github/workflows/release.yml) runs `scripts/prepare-release.mjs`. When there is no explicit changeset, it creates one deterministic patch changeset per unreleased PR or commit. Each release-note entry links the PR (when available) and commit, followed by the change description.
80-
- **Dependency-only releases**: Non-security dependency updates wait until the current release is at least 30 days old. Dependency updates identified as security fixes are released immediately. Substantive non-dependency changes are also released immediately.
81-
- **Release pull request**: The workflow opens or updates a **Release tracking** pull request using the unmodified `changesets/action@v2`. Merging that pull request publishes the package to npm and records the new version and changelog in the repository.
82-
- **Manual runs**: Use the **Run workflow** button for the `Release` workflow (`workflow_dispatch`) to regenerate or refresh release tracking without waiting for another push to `main`.
78+
- **Human-authored pull requests**: For a user-facing change, run `npx changeset`, describe the change, and choose a `patch`, `minor`, or `major` bump. Commit the generated changeset with the change. If a pull request should not produce a release, apply the `skip changeset` label instead.
79+
- **Dependabot pull requests**: Dependabot PRs are exempt from the human changeset check. A dedicated workflow adds one deterministic patch changeset when a Dependabot update resolves a security alert or changes the declared range of a direct production dependency in `package.json`.
80+
- **Dependency updates without releases**: Direct development dependency updates, indirect dependency updates, and lockfile-only updates already permitted by the existing `package.json` range do not get changesets unless they resolve a Dependabot security alert.
81+
- **Dependabot automation credentials**: The Dependabot changeset workflow uses the shared Primer GitHub App credentials (`PRIMER_APP_ID_SHARED` and `PRIMER_APP_PRIVATE_KEY_SHARED`). The App needs Contents write, Pull requests read, and Dependabot alerts read permissions so it can look up security-alert metadata and commit the generated changeset to the Dependabot branch without a personal access token.
82+
- **Release pull request**: The [`release` workflow](.github/workflows/release.yml) opens or updates a **Release tracking** pull request using `changesets/action@v2`. Changesets owns release aggregation, versioning, changelog generation, and publishing. Merging that pull request publishes the package to npm and records the new version and changelog in the repository.
83+
- **Manual runs**: Use the **Run workflow** button for the `Release` workflow (`workflow_dispatch`) to refresh release tracking without waiting for another push to `main`.
8384

8485
## License
8586

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
"build": "tsc --outDir dist/umd --module umd && tsc",
2222
"pretest": "npm run build",
2323
"test": "karma start test/karma.config.js",
24-
"test:release-script": "node --test scripts/*.test.mjs",
25-
"prepare-release": "node scripts/prepare-release.mjs",
24+
"test:dependabot-changeset": "node --test scripts/dependabot-changeset.test.mjs",
2625
"changeset": "changeset",
2726
"release": "changeset publish",
2827
"prepublishOnly": "npm run build",

scripts/dependabot-changeset.mjs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env node
2+
import {Buffer} from 'node:buffer'
3+
import {readFileSync} from 'node:fs'
4+
5+
export const CHANGESET_PREFIX = 'dependabot-'
6+
7+
function parseList(value) {
8+
if (!value) return []
9+
if (Array.isArray(value)) return value.flatMap(parseList)
10+
11+
return String(value)
12+
.split(/[\n,]+/)
13+
.map(item => item.trim())
14+
.filter(Boolean)
15+
}
16+
17+
function unique(values) {
18+
return [...new Set(values)]
19+
}
20+
21+
function getUpdatedDependencyNames(metadata) {
22+
if (!metadata['updated-dependencies-json']) return []
23+
24+
try {
25+
const dependencies = JSON.parse(metadata['updated-dependencies-json'])
26+
if (!Array.isArray(dependencies)) return []
27+
28+
return dependencies
29+
.map(dependency => dependency.dependencyName ?? dependency['dependency-name'] ?? dependency.name)
30+
.filter(Boolean)
31+
} catch {
32+
return []
33+
}
34+
}
35+
36+
export function getDependencyNames(metadata) {
37+
return unique([...getUpdatedDependencyNames(metadata), ...parseList(metadata['dependency-names'] ?? metadata['dependency-name'])])
38+
}
39+
40+
export function isSecurityUpdate(metadata) {
41+
const alertState = String(metadata['alert-state'] ?? '').trim().toLowerCase()
42+
return alertState === 'fixed' || parseList(metadata['ghsa-id']).length > 0
43+
}
44+
45+
export function getProductionRangeChanges({basePackage, headPackage, dependencyNames}) {
46+
const baseDependencies = basePackage.dependencies ?? {}
47+
const headDependencies = headPackage.dependencies ?? {}
48+
49+
return dependencyNames.filter(name => {
50+
if (!(name in baseDependencies) && !(name in headDependencies)) return false
51+
return baseDependencies[name] !== headDependencies[name]
52+
})
53+
}
54+
55+
export function evaluatePolicy({basePackage, headPackage, metadata}) {
56+
const dependencyNames = getDependencyNames(metadata)
57+
const securityUpdate = isSecurityUpdate(metadata)
58+
const productionRangeChanges = getProductionRangeChanges({basePackage, headPackage, dependencyNames})
59+
60+
return {
61+
dependencyNames,
62+
qualifies: securityUpdate || productionRangeChanges.length > 0,
63+
reasons: {
64+
securityUpdate,
65+
productionRangeChanges,
66+
},
67+
}
68+
}
69+
70+
function formatList(items) {
71+
if (items.length === 0) return 'dependencies'
72+
if (items.length === 1) return `\`${items[0]}\``
73+
return `${items.slice(0, -1).map(item => `\`${item}\``).join(', ')} and \`${items.at(-1)}\``
74+
}
75+
76+
export function renderChangeset({packageName, prNumber, policy}) {
77+
const names = policy.dependencyNames
78+
const reason = policy.reasons.securityUpdate
79+
? `Resolve Dependabot security alert(s) for ${formatList(names)}.`
80+
: `Bump production dependency range(s) for ${formatList(policy.reasons.productionRangeChanges)}.`
81+
82+
return `---\n"${packageName}": patch\n---\n\n${reason}\n\nGenerated for Dependabot PR #${prNumber}.\n`
83+
}
84+
85+
function encodePath(path) {
86+
return path.split('/').map(encodeURIComponent).join('/')
87+
}
88+
89+
async function githubRequest(path, {method = 'GET', token, body, accept = 'application/vnd.github+json'} = {}) {
90+
const response = await fetch(`https://api.github.com${path}`, {
91+
method,
92+
headers: {
93+
accept,
94+
authorization: 'Bearer ' + token,
95+
'content-type': 'application/json',
96+
'x-github-api-version': '2022-11-28',
97+
},
98+
body: body === undefined ? undefined : JSON.stringify(body),
99+
})
100+
101+
if (response.status === 404) return undefined
102+
if (!response.ok) {
103+
throw new Error(`${method} ${path} failed with ${response.status}: ${await response.text()}`)
104+
}
105+
106+
return response.json()
107+
}
108+
109+
async function readJsonContent({owner, repo, path, ref, token}) {
110+
const content = await githubRequest(`/repos/${owner}/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`, {
111+
token,
112+
})
113+
if (!content?.content) throw new Error(`Unable to read ${path} at ${ref}`)
114+
return JSON.parse(Buffer.from(content.content, 'base64').toString('utf8'))
115+
}
116+
117+
async function getContent({owner, repo, path, ref, token}) {
118+
return githubRequest(`/repos/${owner}/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`, {token})
119+
}
120+
121+
async function putContent({owner, repo, path, branch, content, token, message, sha}) {
122+
await githubRequest(`/repos/${owner}/${repo}/contents/${encodePath(path)}`, {
123+
method: 'PUT',
124+
token,
125+
body: {
126+
message,
127+
content: Buffer.from(content).toString('base64'),
128+
branch,
129+
sha,
130+
},
131+
})
132+
}
133+
134+
async function deleteContent({owner, repo, path, branch, token, message, sha}) {
135+
await githubRequest(`/repos/${owner}/${repo}/contents/${encodePath(path)}`, {
136+
method: 'DELETE',
137+
token,
138+
body: {message, branch, sha},
139+
})
140+
}
141+
142+
export async function applyDependabotChangeset({event, metadata, token}) {
143+
const pr = event.pull_request
144+
if (!pr) throw new Error('This script must run for a pull request event.')
145+
if (pr.user.login !== 'dependabot[bot]') throw new Error(`Refusing to modify PR authored by ${pr.user.login}.`)
146+
if (pr.head.repo.full_name !== event.repository.full_name) {
147+
throw new Error('Refusing to write to a pull request branch from a different repository.')
148+
}
149+
150+
const [owner, repo] = event.repository.full_name.split('/')
151+
const basePackage = await readJsonContent({owner, repo, path: 'package.json', ref: pr.base.sha, token})
152+
const headPackage = await readJsonContent({owner, repo, path: 'package.json', ref: pr.head.sha, token})
153+
const policy = evaluatePolicy({basePackage, headPackage, metadata})
154+
const changesetPath = `.changeset/${CHANGESET_PREFIX}${pr.number}.md`
155+
const existing = await getContent({owner, repo, path: changesetPath, ref: pr.head.ref, token})
156+
157+
if (!policy.qualifies) {
158+
if (existing) {
159+
await deleteContent({
160+
owner,
161+
repo,
162+
path: changesetPath,
163+
branch: pr.head.ref,
164+
token,
165+
message: `Remove Dependabot changeset for #${pr.number}`,
166+
sha: existing.sha,
167+
})
168+
return {action: 'removed', path: changesetPath, policy}
169+
}
170+
171+
return {action: 'skipped', path: changesetPath, policy}
172+
}
173+
174+
const content = renderChangeset({packageName: headPackage.name, prNumber: pr.number, policy})
175+
if (existing && Buffer.from(existing.content, 'base64').toString('utf8') === content) {
176+
return {action: 'unchanged', path: changesetPath, policy}
177+
}
178+
179+
await putContent({
180+
owner,
181+
repo,
182+
path: changesetPath,
183+
branch: pr.head.ref,
184+
token,
185+
content,
186+
message: `Add Dependabot changeset for #${pr.number}`,
187+
sha: existing?.sha,
188+
})
189+
190+
return {action: existing ? 'updated' : 'created', path: changesetPath, policy}
191+
}
192+
193+
if (import.meta.url === `file://${process.argv[1]}`) {
194+
const event = JSON.parse(process.env.GITHUB_EVENT_JSON ?? readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
195+
const metadata = JSON.parse(process.env.DEPENDABOT_METADATA_JSON)
196+
const token = process.env.GITHUB_TOKEN
197+
if (!token) throw new Error('GITHUB_TOKEN is required.')
198+
199+
applyDependabotChangeset({event, metadata, token})
200+
.then(result => {
201+
console.log(`${result.action} ${result.path}`)
202+
console.log(JSON.stringify(result.policy, null, 2))
203+
})
204+
.catch(error => {
205+
console.error(error)
206+
process.exitCode = 1
207+
})
208+
}

0 commit comments

Comments
 (0)