Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/scripts/dependabot-changeset.js
Original file line number Diff line number Diff line change
@@ -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");
77 changes: 77 additions & 0 deletions .github/workflows/dependabot-changeset.yml
Original file line number Diff line number Diff line change
@@ -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
Loading