diff --git a/.github/scripts/dependabot-changeset.js b/.github/scripts/dependabot-changeset.js new file mode 100644 index 000000000..b9f67abed --- /dev/null +++ b/.github/scripts/dependabot-changeset.js @@ -0,0 +1,113 @@ +/** + * Creates a changeset file for Dependabot PRs that update production + * dependencies in published @knocklabs/* packages. + * + * 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) + * 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'", + { 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) { + const content = JSON.parse(fs.readFileSync(pkgFile, "utf-8")); + const pkgName = content.name || ""; + + if (content.private || !pkgName.startsWith("@knocklabs/")) { + continue; + } + + // Only include packages where production dependencies changed, + // not devDependency-only updates + 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 + } + + 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(`Only devDependency changes in ${pkgName}, skipping`); + continue; + } + + packages.push(pkgName); +} + +const uniquePackages = [...new Set(packages)].sort(); + +if (uniquePackages.length === 0) { + console.log("No published @knocklabs packages were affected"); + setOutput("created", "false"); + process.exit(0); +} + +// Generate changeset file +const lines = [ + "---", + ...uniquePackages.map((pkg) => `"${pkg}": patch`), + "---", + "", + prTitle, + "", +]; + +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..fc4b7fcd9 --- /dev/null +++ b/.github/workflows/dependabot-changeset.yml @@ -0,0 +1,77 @@ +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]' || github.event_name == 'workflow_dispatch' + + steps: + - name: Get PR metadata + 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 [ "$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=$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: + ref: ${{ steps.pr.outputs.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: ${{ steps.pr.outputs.title }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + run: node base/.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