From 538672f902714321282f2c97529f2b5db1c3487a Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Tue, 10 Mar 2026 13:18:35 -0500 Subject: [PATCH 1/5] feat: automatic dependabot pr changesets --- .github/scripts/dependabot-changeset.js | 131 +++++++++++++++++++++ .github/workflows/dependabot-changeset.yml | 40 +++++++ 2 files changed, 171 insertions(+) create mode 100644 .github/scripts/dependabot-changeset.js create mode 100644 .github/workflows/dependabot-changeset.yml diff --git a/.github/scripts/dependabot-changeset.js b/.github/scripts/dependabot-changeset.js new file mode 100644 index 000000000..89fc4dcf4 --- /dev/null +++ b/.github/scripts/dependabot-changeset.js @@ -0,0 +1,131 @@ +/** + * Automatically creates a changeset file for Dependabot PRs that update + * production dependencies in published @knocklabs/* packages. + * + * This script is intended to be run in a GitHub Actions workflow triggered + * by pull_request_target events from dependabot[bot]. + * + * Environment variables: + * PR_TITLE - The pull request title (used as the changeset description) + * PR_NUMBER - The pull request number (used in the changeset filename) + * GITHUB_OUTPUT - Path to the GitHub Actions output file + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const prTitle = process.env.PR_TITLE; +const prNumber = process.env.PR_NUMBER; +const githubOutput = process.env.GITHUB_OUTPUT; + +if (!prTitle || !prNumber || !githubOutput) { + console.error( + "Missing required environment variables: PR_TITLE, PR_NUMBER, GITHUB_OUTPUT", + ); + process.exit(1); +} + +const changesetFile = path.join(".changeset", `dependabot-pr-${prNumber}.md`); + +function setOutput(key, value) { + fs.appendFileSync(githubOutput, `${key}=${value}\n`); +} + +// If changeset already exists, skip +if (fs.existsSync(changesetFile)) { + console.log("Changeset already exists, skipping"); + setOutput("created", "false"); + process.exit(0); +} + +// Find all package.json files changed in the latest commit +const diffOutput = execSync( + "git diff --name-only HEAD~1 HEAD -- '**/package.json' 'package.json'", + { encoding: "utf-8" }, +).trim(); + +if (!diffOutput) { + console.log("No package.json files changed"); + setOutput("created", "false"); + process.exit(0); +} + +const changedFiles = diffOutput.split("\n").filter(Boolean); +const packages = []; + +for (const pkgFile of changedFiles) { + // Skip root package.json (private monorepo root) + if (pkgFile === "package.json") { + continue; + } + + // Get old dependencies + let oldDeps = {}; + try { + const oldContent = execSync(`git show "HEAD~1:${pkgFile}"`, { + encoding: "utf-8", + }); + oldDeps = JSON.parse(oldContent).dependencies || {}; + } catch { + // File may not exist in previous commit (new package) + oldDeps = {}; + } + + // Get new package contents + const newContent = execSync(`git show "HEAD:${pkgFile}"`, { + encoding: "utf-8", + }); + const newPkg = JSON.parse(newContent); + const newDeps = newPkg.dependencies || {}; + + // Compare dependencies using sorted keys for stable comparison + const sortedOld = JSON.stringify(oldDeps, Object.keys(oldDeps).sort()); + const sortedNew = JSON.stringify(newDeps, Object.keys(newDeps).sort()); + + if (sortedOld === sortedNew) { + console.log(`No production dependency changes in ${pkgFile}, skipping`); + continue; + } + + const pkgName = newPkg.name || ""; + const isPrivate = newPkg.private === true; + + if (isPrivate) { + console.log(`Skipping private package: ${pkgName}`); + continue; + } + + if (!pkgName.startsWith("@knocklabs/")) { + console.log(`Skipping non-@knocklabs package: ${pkgName}`); + continue; + } + + packages.push(pkgName); +} + +// Deduplicate and sort +const uniquePackages = [...new Set(packages)].sort(); + +if (uniquePackages.length === 0) { + console.log("No production dependency changes in published packages"); + setOutput("created", "false"); + process.exit(0); +} + +// Generate changeset file +const lines = [ + "---", + ...uniquePackages.map((pkg) => `"${pkg}": patch`), + "---", + "", + prTitle, + "", +]; + +fs.mkdirSync(path.dirname(changesetFile), { recursive: true }); +fs.writeFileSync(changesetFile, lines.join("\n")); + +console.log(`Created changeset: ${changesetFile}`); +console.log(fs.readFileSync(changesetFile, "utf-8")); +setOutput("created", "true"); diff --git a/.github/workflows/dependabot-changeset.yml b/.github/workflows/dependabot-changeset.yml new file mode 100644 index 000000000..cf2b21f7a --- /dev/null +++ b/.github/workflows/dependabot-changeset.yml @@ -0,0 +1,40 @@ +name: Dependabot Changeset + +on: + pull_request_target: + types: [opened, synchronize] + +jobs: + changeset: + name: Add changeset for dependency update + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.KNOCK_ENG_BOT_GITHUB_TOKEN }} + fetch-depth: 2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + + - name: Detect affected packages and create changeset + id: changeset + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: node .github/scripts/dependabot-changeset.js + + - name: Commit and push changeset + if: steps.changeset.outputs.created == 'true' + run: | + git config user.name "knock-eng-bot" + git config user.email "knock-eng-bot@users.noreply.github.com" + git add .changeset/ + git commit -m "chore(deps): add changeset for dependency update" + git push From 4555fe62f40e4a3eb5879f30e918cc9064f186d3 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Tue, 10 Mar 2026 13:22:46 -0500 Subject: [PATCH 2/5] fix: simplify script --- .github/scripts/dependabot-changeset.js | 52 ++++++++----------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/.github/scripts/dependabot-changeset.js b/.github/scripts/dependabot-changeset.js index 89fc4dcf4..b9f67abed 100644 --- a/.github/scripts/dependabot-changeset.js +++ b/.github/scripts/dependabot-changeset.js @@ -1,9 +1,11 @@ /** - * Automatically creates a changeset file for Dependabot PRs that update - * production dependencies in published @knocklabs/* packages. + * Creates a changeset file for Dependabot PRs that update production + * dependencies in published @knocklabs/* packages. * - * This script is intended to be run in a GitHub Actions workflow triggered - * by pull_request_target events from dependabot[bot]. + * Skips devDependency-only changes since those don't require a release. + * + * This script is only run from a workflow gated to dependabot[bot], + * so we trust that the changes are dependency updates. * * Environment variables: * PR_TITLE - The pull request title (used as the changeset description) @@ -41,7 +43,7 @@ if (fs.existsSync(changesetFile)) { // Find all package.json files changed in the latest commit const diffOutput = execSync( - "git diff --name-only HEAD~1 HEAD -- '**/package.json' 'package.json'", + "git diff --name-only HEAD~1 HEAD -- '**/package.json'", { encoding: "utf-8" }, ).trim(); @@ -55,12 +57,15 @@ const changedFiles = diffOutput.split("\n").filter(Boolean); const packages = []; for (const pkgFile of changedFiles) { - // Skip root package.json (private monorepo root) - if (pkgFile === "package.json") { + const content = JSON.parse(fs.readFileSync(pkgFile, "utf-8")); + const pkgName = content.name || ""; + + if (content.private || !pkgName.startsWith("@knocklabs/")) { continue; } - // Get old dependencies + // Only include packages where production dependencies changed, + // not devDependency-only updates let oldDeps = {}; try { const oldContent = execSync(`git show "HEAD~1:${pkgFile}"`, { @@ -68,47 +73,25 @@ for (const pkgFile of changedFiles) { }); oldDeps = JSON.parse(oldContent).dependencies || {}; } catch { - // File may not exist in previous commit (new package) - oldDeps = {}; + // File may not exist in previous commit } - // Get new package contents - const newContent = execSync(`git show "HEAD:${pkgFile}"`, { - encoding: "utf-8", - }); - const newPkg = JSON.parse(newContent); - const newDeps = newPkg.dependencies || {}; - - // Compare dependencies using sorted keys for stable comparison + const newDeps = content.dependencies || {}; const sortedOld = JSON.stringify(oldDeps, Object.keys(oldDeps).sort()); const sortedNew = JSON.stringify(newDeps, Object.keys(newDeps).sort()); if (sortedOld === sortedNew) { - console.log(`No production dependency changes in ${pkgFile}, skipping`); - continue; - } - - const pkgName = newPkg.name || ""; - const isPrivate = newPkg.private === true; - - if (isPrivate) { - console.log(`Skipping private package: ${pkgName}`); - continue; - } - - if (!pkgName.startsWith("@knocklabs/")) { - console.log(`Skipping non-@knocklabs package: ${pkgName}`); + console.log(`Only devDependency changes in ${pkgName}, skipping`); continue; } packages.push(pkgName); } -// Deduplicate and sort const uniquePackages = [...new Set(packages)].sort(); if (uniquePackages.length === 0) { - console.log("No production dependency changes in published packages"); + console.log("No published @knocklabs packages were affected"); setOutput("created", "false"); process.exit(0); } @@ -123,7 +106,6 @@ const lines = [ "", ]; -fs.mkdirSync(path.dirname(changesetFile), { recursive: true }); fs.writeFileSync(changesetFile, lines.join("\n")); console.log(`Created changeset: ${changesetFile}`); From f8b72e3e5b25f5848e32e76d5af3e94d568bc23b Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Tue, 10 Mar 2026 13:23:55 -0500 Subject: [PATCH 3/5] fix: remove trigger --- .github/workflows/dependabot-changeset.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependabot-changeset.yml b/.github/workflows/dependabot-changeset.yml index cf2b21f7a..ce6ad4676 100644 --- a/.github/workflows/dependabot-changeset.yml +++ b/.github/workflows/dependabot-changeset.yml @@ -2,7 +2,7 @@ name: Dependabot Changeset on: pull_request_target: - types: [opened, synchronize] + types: [opened] jobs: changeset: From bc5738c590b5d08d744a8529502a5d58c762d0a0 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 11 Mar 2026 08:58:13 -0500 Subject: [PATCH 4/5] feat: manually trigger --- .github/workflows/dependabot-changeset.yml | 31 +++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dependabot-changeset.yml b/.github/workflows/dependabot-changeset.yml index ce6ad4676..a718ee73a 100644 --- a/.github/workflows/dependabot-changeset.yml +++ b/.github/workflows/dependabot-changeset.yml @@ -3,18 +3,41 @@ name: Dependabot Changeset on: pull_request_target: types: [opened] + workflow_dispatch: + inputs: + pr-number: + description: "Pull request number to add a changeset for" + required: true + type: string jobs: changeset: name: Add changeset for dependency update runs-on: ubuntu-latest - if: github.actor == 'dependabot[bot]' + if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch' steps: + - name: Get PR metadata + id: pr + env: + GH_TOKEN: ${{ secrets.KNOCK_ENG_BOT_GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_NUMBER="${{ inputs.pr-number }}" + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json title,headRefName) + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "title=$(echo "$PR_JSON" | jq -r '.title')" >> "$GITHUB_OUTPUT" + echo "ref=$(echo "$PR_JSON" | jq -r '.headRefName')" >> "$GITHUB_OUTPUT" + else + echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + echo "title=${{ github.event.pull_request.title }}" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT" + fi + - name: Checkout PR branch uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ steps.pr.outputs.ref }} token: ${{ secrets.KNOCK_ENG_BOT_GITHUB_TOKEN }} fetch-depth: 2 @@ -26,8 +49,8 @@ jobs: - name: Detect affected packages and create changeset id: changeset env: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ steps.pr.outputs.title }} + PR_NUMBER: ${{ steps.pr.outputs.number }} run: node .github/scripts/dependabot-changeset.js - name: Commit and push changeset From 323e9544d196d5315e0b77f58b208d402601727e Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 11 Mar 2026 09:15:14 -0500 Subject: [PATCH 5/5] fix: pr feedback --- .github/workflows/dependabot-changeset.yml | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/dependabot-changeset.yml b/.github/workflows/dependabot-changeset.yml index a718ee73a..fc4b7fcd9 100644 --- a/.github/workflows/dependabot-changeset.yml +++ b/.github/workflows/dependabot-changeset.yml @@ -21,19 +21,33 @@ jobs: id: pr env: GH_TOKEN: ${{ secrets.KNOCK_ENG_BOT_GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + INPUT_PR_NUMBER: ${{ inputs.pr-number }} + REPO: ${{ github.repository }} + PR_NUMBER_FROM_EVENT: ${{ github.event.pull_request.number }} + PR_TITLE_FROM_EVENT: ${{ github.event.pull_request.title }} + PR_REF_FROM_EVENT: ${{ github.event.pull_request.head.ref }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - PR_NUMBER="${{ inputs.pr-number }}" - PR_JSON=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json title,headRefName) - echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "$REPO" --json title,headRefName) + echo "number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT" echo "title=$(echo "$PR_JSON" | jq -r '.title')" >> "$GITHUB_OUTPUT" echo "ref=$(echo "$PR_JSON" | jq -r '.headRefName')" >> "$GITHUB_OUTPUT" else - echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" - echo "title=${{ github.event.pull_request.title }}" >> "$GITHUB_OUTPUT" - echo "ref=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT" + echo "number=$PR_NUMBER_FROM_EVENT" >> "$GITHUB_OUTPUT" + echo "title=$PR_TITLE_FROM_EVENT" >> "$GITHUB_OUTPUT" + echo "ref=$PR_REF_FROM_EVENT" >> "$GITHUB_OUTPUT" fi + # Checkout the base branch to get the trusted version of the script, + # then checkout the PR branch on top to get the package.json changes. + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.KNOCK_ENG_BOT_GITHUB_TOKEN }} + path: base + - name: Checkout PR branch uses: actions/checkout@v4 with: @@ -51,7 +65,7 @@ jobs: env: PR_TITLE: ${{ steps.pr.outputs.title }} PR_NUMBER: ${{ steps.pr.outputs.number }} - run: node .github/scripts/dependabot-changeset.js + run: node base/.github/scripts/dependabot-changeset.js - name: Commit and push changeset if: steps.changeset.outputs.created == 'true'