From 7b8bfcd56a283521591ba1fac291da0f3c0c9222 Mon Sep 17 00:00:00 2001 From: Adam Grossman Date: Tue, 13 Oct 2026 07:45:16 -0700 Subject: [PATCH 1/2] Check dependencies daily and auto-merge verified patches --- .github/dependabot-automerge.mjs | 102 ++++++++++++++++++ .github/dependabot-automerge.test.mjs | 108 ++++++++++++++++++++ .github/dependabot.yml | 6 +- .github/workflows/ci.yaml | 2 + .github/workflows/dependabot-automerge.yaml | 39 +++++++ 5 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot-automerge.mjs create mode 100644 .github/dependabot-automerge.test.mjs create mode 100644 .github/workflows/dependabot-automerge.yaml diff --git a/.github/dependabot-automerge.mjs b/.github/dependabot-automerge.mjs new file mode 100644 index 0000000..50a723c --- /dev/null +++ b/.github/dependabot-automerge.mjs @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const stableVersion = /^\d+\.\d+\.\d+$/; +const githubActionsAppId = 15368; +const isDevvit = (name) => name === 'devvit' || name.startsWith('@devvit/'); + +export function ineligibleReason({ dependencies, files, manifest, rules }) { + if ( + dependencies.length === 0 || + dependencies.some( + (dependency) => + dependency.updateType !== 'version-update:semver-patch' || + !stableVersion.test(dependency.prevVersion) || + !stableVersion.test(dependency.newVersion) + ) + ) { + return 'Every dependency must be a stable patch update.'; + } + + if ( + files.length !== 1 || + files[0].filename !== 'package.json' || + files[0].status !== 'modified' + ) { + return 'Only package.json dependency updates may auto-merge.'; + } + + if (dependencies.some((dependency) => isDevvit(dependency.dependencyName))) { + const versions = Object.entries({ + ...manifest.dependencies, + ...manifest.devDependencies, + }) + .filter(([name]) => isDevvit(name)) + .map(([, version]) => version); + if ( + versions.length === 0 || + versions.some( + (version) => !stableVersion.test(version) || version !== versions[0] + ) + ) { + return 'Devvit packages must be pinned to the same stable version.'; + } + } + + if ( + !rules.some( + (rule) => + rule.type === 'required_status_checks' && + rule.parameters.strict_required_status_checks_policy && + rule.parameters.required_status_checks.some( + (check) => + check.context === 'test' && + check.integration_id === githubActionsAppId + ) + ) + ) { + return 'Require the GitHub Actions test check and an up-to-date branch in a main ruleset before enabling auto-merge.'; + } + + return undefined; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const { GH_REPO, PR_NUMBER, PR_HEAD_SHA, UPDATED_DEPENDENCIES } = process.env; + const api = (path) => + JSON.parse(execFileSync('gh', ['api', path], { encoding: 'utf8' })); + const reason = ineligibleReason({ + dependencies: JSON.parse(UPDATED_DEPENDENCIES), + files: api(`repos/${GH_REPO}/pulls/${PR_NUMBER}/files?per_page=100`), + manifest: JSON.parse( + Buffer.from( + api(`repos/${GH_REPO}/contents/package.json?ref=${PR_HEAD_SHA}`) + .content, + 'base64' + ).toString('utf8') + ), + rules: api(`repos/${GH_REPO}/rules/branches/main`), + }); + if (reason) { + console.log(`Leaving PR #${PR_NUMBER} for manual review: ${reason}`); + } else { + execFileSync( + 'gh', + [ + 'pr', + 'merge', + PR_NUMBER, + '--repo', + GH_REPO, + '--auto', + '--squash', + '--match-head-commit', + PR_HEAD_SHA, + ], + { stdio: 'inherit' } + ); + } +} diff --git a/.github/dependabot-automerge.test.mjs b/.github/dependabot-automerge.test.mjs new file mode 100644 index 0000000..937b12d --- /dev/null +++ b/.github/dependabot-automerge.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ineligibleReason } from './dependabot-automerge.mjs'; + +const patch = (dependencyName) => ({ + dependencyName, + updateType: 'version-update:semver-patch', + prevVersion: '0.14.7', + newVersion: '0.14.8', +}); +const eligible = { + dependencies: [patch('devvit'), patch('@devvit/web')], + files: [{ filename: 'package.json', status: 'modified' }], + manifest: { + dependencies: { devvit: '0.14.8', '@devvit/web': '0.14.8' }, + devDependencies: { '@devvit/start': '0.14.8' }, + }, + rules: [ + { + type: 'required_status_checks', + parameters: { + strict_required_status_checks_policy: true, + required_status_checks: [{ context: 'test', integration_id: 15368 }], + }, + }, + ], +}; + +test('allows grouped Devvit patches and third-party patches behind required CI', () => { + assert.equal(ineligibleReason(eligible), undefined); + assert.equal( + ineligibleReason({ ...eligible, dependencies: [patch('hono')] }), + undefined + ); +}); + +const blocked = [ + [ + 'missing metadata', + (input) => { + input.dependencies = []; + }, + ], + [ + 'a group containing a minor upgrade', + (input) => { + input.dependencies[1].updateType = 'version-update:semver-minor'; + }, + ], + [ + 'a group containing an unknown update', + (input) => { + input.dependencies[1].updateType = ''; + }, + ], + [ + 'a prerelease patch', + (input) => { + input.dependencies[0].newVersion = '0.14.8-next.1'; + }, + ], + [ + 'changes outside the manifest', + (input) => { + input.files.push({ filename: 'src/server/index.ts' }); + }, + ], + [ + 'mismatched Devvit packages', + (input) => { + input.manifest.devDependencies['@devvit/start'] = '0.14.7'; + }, + ], + [ + 'an unpinned Devvit version', + (input) => { + input.manifest = { + dependencies: { devvit: '^0.14.8', '@devvit/web': '^0.14.8' }, + }; + }, + ], + [ + 'CI that is not required', + (input) => { + input.rules = []; + }, + ], + [ + 'CI from another provider', + (input) => { + input.rules[0].parameters.required_status_checks[0].integration_id = 1; + }, + ], + [ + 'CI without requiring an up-to-date branch', + (input) => { + input.rules[0].parameters.strict_required_status_checks_policy = false; + }, + ], +]; + +for (const [name, changes] of blocked) { + test(`leaves ${name} for manual review`, () => { + const input = structuredClone(eligible); + changes(input); + assert.ok(ineligibleReason(input)); + }); +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cf2a042..8fff6aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,11 @@ -# Dependabot configuration to update npm packages weekly +# Dependabot configuration to update npm packages daily version: 2 updates: - package-ecosystem: "npm" directory: "/" # Location of your package.json and package-lock.json schedule: - interval: "cron" - cronjob: "0 17 * * 1,2" # At 5pm on Monday and Tuesday - https://crontab.guru/ + interval: "daily" + time: "17:00" timezone: "America/New_York" open-pull-requests-limit: 10 # Optional: prevents PR spam by limiting active updates ignore: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ee9d3d..930ac95 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,8 @@ jobs: uses: actions/setup-node@v6.4.0 with: node-version: 24.18.0 + - name: Test Dependabot merge policy + run: node --test .github/dependabot-automerge.test.mjs - name: Install Dependencies run: npm install --no-fund - parallel: diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml new file mode 100644 index 0000000..b60d0a6 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yaml @@ -0,0 +1,39 @@ +name: Dependabot patch auto-merge + +on: + pull_request_target: + branches: ['main'] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + auto-merge: + if: >- + github.repository == 'reddit/devvit-template-react' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # pull_request_target has write access: execute only the trusted base revision. + - name: Checkout merge policy + uses: actions/checkout@v7.0.0 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - name: Verify Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3 + - name: Enable auto-merge for eligible patches + run: node .github/dependabot-automerge.mjs + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + UPDATED_DEPENDENCIES: ${{ steps.metadata.outputs.updated-dependencies-json }} From 59e3d772182e94add1818fa75cc1932344027e79 Mon Sep 17 00:00:00 2001 From: Adam Grossman Date: Tue, 13 Oct 2026 07:58:43 -0700 Subject: [PATCH 2/2] Use native GitHub auto-merge for Dependabot patches --- .github/dependabot-automerge.mjs | 102 ------------------ .github/dependabot-automerge.test.mjs | 108 -------------------- .github/workflows/ci.yaml | 2 - .github/workflows/dependabot-automerge.yaml | 20 +--- 4 files changed, 5 insertions(+), 227 deletions(-) delete mode 100644 .github/dependabot-automerge.mjs delete mode 100644 .github/dependabot-automerge.test.mjs diff --git a/.github/dependabot-automerge.mjs b/.github/dependabot-automerge.mjs deleted file mode 100644 index 50a723c..0000000 --- a/.github/dependabot-automerge.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { pathToFileURL } from 'node:url'; - -const stableVersion = /^\d+\.\d+\.\d+$/; -const githubActionsAppId = 15368; -const isDevvit = (name) => name === 'devvit' || name.startsWith('@devvit/'); - -export function ineligibleReason({ dependencies, files, manifest, rules }) { - if ( - dependencies.length === 0 || - dependencies.some( - (dependency) => - dependency.updateType !== 'version-update:semver-patch' || - !stableVersion.test(dependency.prevVersion) || - !stableVersion.test(dependency.newVersion) - ) - ) { - return 'Every dependency must be a stable patch update.'; - } - - if ( - files.length !== 1 || - files[0].filename !== 'package.json' || - files[0].status !== 'modified' - ) { - return 'Only package.json dependency updates may auto-merge.'; - } - - if (dependencies.some((dependency) => isDevvit(dependency.dependencyName))) { - const versions = Object.entries({ - ...manifest.dependencies, - ...manifest.devDependencies, - }) - .filter(([name]) => isDevvit(name)) - .map(([, version]) => version); - if ( - versions.length === 0 || - versions.some( - (version) => !stableVersion.test(version) || version !== versions[0] - ) - ) { - return 'Devvit packages must be pinned to the same stable version.'; - } - } - - if ( - !rules.some( - (rule) => - rule.type === 'required_status_checks' && - rule.parameters.strict_required_status_checks_policy && - rule.parameters.required_status_checks.some( - (check) => - check.context === 'test' && - check.integration_id === githubActionsAppId - ) - ) - ) { - return 'Require the GitHub Actions test check and an up-to-date branch in a main ruleset before enabling auto-merge.'; - } - - return undefined; -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - const { GH_REPO, PR_NUMBER, PR_HEAD_SHA, UPDATED_DEPENDENCIES } = process.env; - const api = (path) => - JSON.parse(execFileSync('gh', ['api', path], { encoding: 'utf8' })); - const reason = ineligibleReason({ - dependencies: JSON.parse(UPDATED_DEPENDENCIES), - files: api(`repos/${GH_REPO}/pulls/${PR_NUMBER}/files?per_page=100`), - manifest: JSON.parse( - Buffer.from( - api(`repos/${GH_REPO}/contents/package.json?ref=${PR_HEAD_SHA}`) - .content, - 'base64' - ).toString('utf8') - ), - rules: api(`repos/${GH_REPO}/rules/branches/main`), - }); - if (reason) { - console.log(`Leaving PR #${PR_NUMBER} for manual review: ${reason}`); - } else { - execFileSync( - 'gh', - [ - 'pr', - 'merge', - PR_NUMBER, - '--repo', - GH_REPO, - '--auto', - '--squash', - '--match-head-commit', - PR_HEAD_SHA, - ], - { stdio: 'inherit' } - ); - } -} diff --git a/.github/dependabot-automerge.test.mjs b/.github/dependabot-automerge.test.mjs deleted file mode 100644 index 937b12d..0000000 --- a/.github/dependabot-automerge.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { ineligibleReason } from './dependabot-automerge.mjs'; - -const patch = (dependencyName) => ({ - dependencyName, - updateType: 'version-update:semver-patch', - prevVersion: '0.14.7', - newVersion: '0.14.8', -}); -const eligible = { - dependencies: [patch('devvit'), patch('@devvit/web')], - files: [{ filename: 'package.json', status: 'modified' }], - manifest: { - dependencies: { devvit: '0.14.8', '@devvit/web': '0.14.8' }, - devDependencies: { '@devvit/start': '0.14.8' }, - }, - rules: [ - { - type: 'required_status_checks', - parameters: { - strict_required_status_checks_policy: true, - required_status_checks: [{ context: 'test', integration_id: 15368 }], - }, - }, - ], -}; - -test('allows grouped Devvit patches and third-party patches behind required CI', () => { - assert.equal(ineligibleReason(eligible), undefined); - assert.equal( - ineligibleReason({ ...eligible, dependencies: [patch('hono')] }), - undefined - ); -}); - -const blocked = [ - [ - 'missing metadata', - (input) => { - input.dependencies = []; - }, - ], - [ - 'a group containing a minor upgrade', - (input) => { - input.dependencies[1].updateType = 'version-update:semver-minor'; - }, - ], - [ - 'a group containing an unknown update', - (input) => { - input.dependencies[1].updateType = ''; - }, - ], - [ - 'a prerelease patch', - (input) => { - input.dependencies[0].newVersion = '0.14.8-next.1'; - }, - ], - [ - 'changes outside the manifest', - (input) => { - input.files.push({ filename: 'src/server/index.ts' }); - }, - ], - [ - 'mismatched Devvit packages', - (input) => { - input.manifest.devDependencies['@devvit/start'] = '0.14.7'; - }, - ], - [ - 'an unpinned Devvit version', - (input) => { - input.manifest = { - dependencies: { devvit: '^0.14.8', '@devvit/web': '^0.14.8' }, - }; - }, - ], - [ - 'CI that is not required', - (input) => { - input.rules = []; - }, - ], - [ - 'CI from another provider', - (input) => { - input.rules[0].parameters.required_status_checks[0].integration_id = 1; - }, - ], - [ - 'CI without requiring an up-to-date branch', - (input) => { - input.rules[0].parameters.strict_required_status_checks_policy = false; - }, - ], -]; - -for (const [name, changes] of blocked) { - test(`leaves ${name} for manual review`, () => { - const input = structuredClone(eligible); - changes(input); - assert.ok(ineligibleReason(input)); - }); -} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 930ac95..3ee9d3d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,8 +17,6 @@ jobs: uses: actions/setup-node@v6.4.0 with: node-version: 24.18.0 - - name: Test Dependabot merge policy - run: node --test .github/dependabot-automerge.test.mjs - name: Install Dependencies run: npm install --no-fund - parallel: diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index b60d0a6..ae28160 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -6,34 +6,24 @@ on: types: [opened, synchronize, reopened, ready_for_review] permissions: - contents: read + contents: write + pull-requests: write jobs: auto-merge: if: >- github.repository == 'reddit/devvit-template-react' && github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write steps: - # pull_request_target has write access: execute only the trusted base revision. - - name: Checkout merge policy - uses: actions/checkout@v7.0.0 - with: - ref: ${{ github.event.pull_request.base.sha }} - persist-credentials: false - name: Verify Dependabot metadata id: metadata uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3 - name: Enable auto-merge for eligible patches - run: node .github/dependabot-automerge.mjs + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr merge --auto --squash --match-head-commit "$PR_HEAD_SHA" "$PR_URL" env: GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - UPDATED_DEPENDENCIES: ${{ steps.metadata.outputs.updated-dependencies-json }}